🧠 LLM & AI Infra · 10 分钟一课
一套给工程师 的 LLM 学习材料:从「模型是怎么学的」一路讲到现代 LLM 架构(GQA / MLA / MoE / RoPE / Hybrid)、训练流水线(3D 并行 / SFT / RLHF / GRPO),再到 AI Infra ——vLLM、SGLang 到底在做什么(KV Cache、PagedAttention、Continuous Batching、RadixAttention、Speculative Decoding、PD 分离)。
🆕 2026-10 更新 :对照 Stanford CS336: Language Modeling from Scratch (Spring 2026)查漏补缺,新增 4 课——18a Scaling Laws 实战 、34a Evaluation 、34b 多模态 、40a Kernel 实战 ;并新增附录 CS336 对照与动手路线 :每个 Part 对应一个 CS336 作业,附几小时可完成的「最小动手版」。
设计原则
⏱ 每课 10 分钟 :碎片时间打开任意一课,读完「一句话」就有所得,读完全课能讲给别人听
🧩 重理解、轻推导 :每个概念讲清「解决什么问题 · 和相邻概念的区别 · 代价/取舍」
🔤 术语英文优先 :Attention、KV Cache、Prefill……不硬翻,和论文、源码、同事讨论时词汇直接对得上
👀 代码只需读懂 :短片段(≤30 行)+ 逐行解读,无需动手跑
🎬 对标经典课程 :Andrej Karpathy「Neural Networks: Zero to Hero」+ 李沐「动手学深度学习 / 论文精读」+ Stanford CS336,每课末尾标注对应视频 / 讲次
💬 能聊的要点 :每课 3 句可以直接在讨论/面试里说出口的话
🛠 可选动手层 :默认只需读懂;想动手时,每个 Part 对应一个 CS336 作业的「最小动手版」,见 CS336 对照与动手路线
每课结构 :一句话 → 为什么要关心 → 核心直觉(图解)→ 代码片段 → 工程视角 → 💬 能聊的要点 → 常见误区 → 自测 → 延伸(视频/论文)
怎么读
推荐路径
🚶 完整路线(约 8 周,每天 1 课) :00 → 47 顺序读
🏃 Infra 快速路径(约 3 周) :00 → 05 → 10–16 → 19–21 → 28–30 → 35–47
🔎 只想看懂新模型发布 :13 → 14 → 15 → 16 → 19 → 20 → 21 → 22 → 23 → 24 → 26
🎤 准备面试/讨论 :career-map 里的「20 个问题」+ 闪卡顶部的「面试高频 20 题」
🛠 读 + 动手(约 5 个周末) :每读完一个 Part,做 CS336 对照 里对应作业的最小动手版
阅读方式 :dist/ai-course.html 是单文件离线网页(手机 / 电脑均可,带目录、搜索、已读进度);也可以直接在 GitHub/IDE 里看 Markdown。
更新构建 :python3 tools/gen_readme.py && node tools/build.mjs
全部课程
Part 0 · 开篇
Part 1 · Foundations:模型是怎么「学」的
Part 2 · Transformer 从零理解
Part 3 · Modern LLM Architecture
Part 4 · Training Pipeline:从 Pretrain 到 Reasoning Model
Part 5 · Inference & AI Infra:vLLM / SGLang 到底在做什么
附录
00 · The Big Picture:一个 LLM 从数据到上线的完整链路
⏱ 10 分钟 | 前置:无 | 难度:★☆☆
一句话
一个 LLM 的一生是:Data → Pretraining(学会续写)→ Post-training(学会听话/推理)→ Serving(高效地把 token 吐出来)→ Apps ;AI Infra 的工作分布在「训练」和「推理」两端,而这门课就是沿着这条链路走一遍。
为什么要关心
做后端/数据的人听 AI 讨论,最容易卡在「名词都听过,但不知道它在链路的哪一环」:ZeRO 是训练的事还是推理的事?KV Cache 为什么训练时不怎么提?DPO 和 SFT 谁先谁后?先把地图画出来,后面 50 多课每学一个概念,你都能把它钉在地图上的准确位置。
核心直觉
1. 生命周期总图
Internet text (web pages, code, books, papers ...)
│ crawl → filter → dedup → mix ← 27
▼
┌──────────────────────────┐
│ Tokenizer (BPE) │ text ⇄ token ids ← 06
└──────────────────────────┘
▼ 数万亿 tokens
┌──────────────────────────┐
│ Pretraining │ next-token prediction
│ Transformer, 千~万卡 GPU │ 数周~数月 ← 01-30
└──────────────────────────┘
▼ Base model:会「续写文档」,不会「听指令」
┌──────────────────────────┐
│ Post-training │ SFT → RLHF / DPO
│ │ → RLVR (reasoning) ← 31-34
└──────────────────────────┘
▼ Instruct / Reasoning model(一堆权重文件)
┌──────────────────────────┐
│ Serving / Inference │ vLLM / SGLang
│ Prefill + Decode │ KV Cache, batching ← 35-47
└──────────────────────────┘
▼ OpenAI-compatible HTTP API
Apps:Chat、Copilot、RAG、Agents
2. 每一环在干什么(用后端工程师熟悉的话说)
Data :本质是一条超大规模的 ETL 管线。例如 HuggingFace 的 FineWeb 从 Common Crawl 清洗出约 15T tokens 的英文网页数据。难点是质量过滤、去重、配比,而不是存储。
Tokenizer :把字符串切成整数序列(token ids)。Token 是模型处理文本的最小单位,通常是一个词或词的一部分(英文平均 1 token ≈ 4 个字符)。模型只看得见整数,看不见字符。
Pretraining :让模型在海量文本上做同一件事——预测下一个 token 。模型是 Transformer (一种神经网络架构,Part 2 讲),里面有几十亿个可调的数字,叫 Parameters / Weights(参数/权重) ;训练就是不断微调这些数字,让预测更准。这一步花掉绝大多数算力。例如 DeepSeek-V3 技术报告给出:预训练 14.8T tokens,完整训练约 2.788M H800 GPU hours。产物叫 Base model :你问它「法国首都是?」,它可能接着写出另外三道地理题,因为它只会「续写互联网文档」。
Post-training :
SFT(Supervised Fine-Tuning,监督微调) :用几万到几百万条「指令 → 理想回答」对话数据微调,让它学会助手的格式和语气。
RLHF / DPO (Reinforcement Learning from Human Feedback / Direct Preference Optimization):用人类偏好(A 回答比 B 好)继续调,让它更有用、更安全。代表作 InstructGPT(2022)。
RLVR / Reasoning (RL with Verifiable Rewards):在数学、代码这类「答案可验证」的任务上做强化学习(RL:让模型自己尝试,答对给奖励、答错不给,据此调整),模型学会先写长长的思考再作答,代表作 OpenAI o1、DeepSeek-R1。
Serving :把权重加载进 GPU,接住并发请求,一个 token 一个 token 地生成。这里的核心矛盾是 GPU 显存和带宽 ,vLLM(PagedAttention)、SGLang(RadixAttention)等引擎就是为此而生。
Apps :在 API 之上拼 Prompt、检索(RAG)、工具调用(Agents)。本课程不重点展开。
3. 训练 vs 推理:两种完全不同的工程问题
Training
Inference / Serving
目标
把 loss 降下去
低延迟、高吞吐、低成本
形态
一个超大离线 batch job
在线服务,请求随机到达
瓶颈
算力(FLOPs)+ 多卡通信
显存容量 + 显存带宽
关键词
AdamW、BF16/FP8、ZeRO/FSDP、TP/PP/EP
KV Cache、Continuous Batching、Prefix Caching、投机解码、量化
成本特征
一次性巨额
持续、与用户量成正比
表里的关键词现在不认识没关系,每个都有专门的课。先建立两个词的直觉:
Compute-bound(算力受限) :GPU 的计算单元一直忙,瓶颈是「算得不够快」——像 CPU 打满的批处理任务。训练属于这种。FLOPs (浮点运算次数)是衡量「算了多少」的单位。
Memory-bound(带宽受限) :计算单元大部分时间在等数据从显存搬过来,瓶颈是「搬得不够快」——像一个每次请求都要全表扫描、CPU 却很闲的查询。推理的 Decode 阶段(逐个生成 token)属于这种;KV Cache 是推理时缓存的历史中间结果,占大量显存(第 37 课)。
一个好记的说法(来自 Karpathy 的 Intro to Large Language Models):一个开源 LLM 本质上就是两个文件 ——一个巨大的参数文件(Llama 2 70B 用 FP16 存约 140GB)和一段几百行的推理代码。训练是「把互联网压缩成参数文件」,推理是「用这段代码反复跑这个文件」。
4. 本课程地图
Part
目录
课号
你会搞懂
0 开篇
00-intro/
00
这张全景图
1 基础
01-foundations/
01–09
模型怎么「学」:loss、梯度、反向传播、token、embedding、训练循环
2 Transformer
02-transformer/
10–17
Attention、多头、位置编码、Norm、FFN、nanoGPT、解码策略
3 现代架构
03-modern-arch/
18–26(含 18a)
Scaling Laws 与超参外推、GQA、MLA、MoE、长上下文、SSM/Hybrid、主流模型配置对照
4 训练流水线
04-training/
27–34(含 34a、34b)
数据、混合精度、分布式训练、SFT、RLHF/DPO、GRPO、LoRA、评测、多模态
5 推理与 Infra
05-infra/
35–47(含 40a)
GPU、Prefill/Decode、KV Cache、PagedAttention、FlashAttention、Kernel 与 Triton、调度、量化、PD 分离、vLLM/SGLang 走读
附录
99-appendix/
—
术语表、闪卡、视频对照表、转岗地图、CS336 对照与动手路线
5. 推荐阅读路径
完整路径(推荐) :00 → 47 顺序读,约 8 小时,每天 3–4 课两周读完。
只想懂 Infra 的快速路径 (约 25 课):
00 → 03 → 05 → 06 → 08 → 09(基础里最关键的几课)
→ 10 → 11 → 12 → 16 → 17(Transformer 最小闭环)
→ 19 → 21 → 26(KV Cache 与 MoE 相关的架构)
→ 28 → 29 → 30(训练侧并行,面试高频)
→ 35 – 47(推理 Infra 全部)
只想懂训练/对齐 :Part 1 → Part 2 → 18、21、22 → Part 4 全部。
面试前冲刺 :只读每课的「一句话」+「💬 能聊的要点」+「自测」,再刷附录的 flashcards。
代码片段
用十几行伪代码把整条链路串起来——后面每一课都在展开其中某一行:
# ===== Pretraining:在海量文本上学「预测下一个 token」=====
ids = tokenizer.encode(huge_text) # 06: BPE 切词 → 整数序列
for x, y in batches(ids): # y 是 x 右移一位(下一个 token)
logits = model(x) # 07/10-16: Embedding + Transformer;logits = 词表里每个 token 的原始打分
loss = cross_entropy(logits, y) # 08: loss = 「预测有多差」的一个数字,越准越低
loss.backward() # 03: Backpropagation 算梯度
optimizer.step() # 02/09: AdamW 更新参数
# ===== Post-training:同样的循环,换成对话数据 / 偏好数据 / RL 奖励 =====
finetune(model, sft_conversations) # 31
rl_update(model, reward_fn) # 32-33
# ===== Serving:自回归生成,一次只吐一个 token =====
ids = tokenizer.encode(prompt)
while not done:
logits = model(ids)[-1] # 36: Prefill 一次 + Decode 多次
next_id = sample(logits, temperature=0.7) # 08/17: 采样策略
ids.append(next_id) # 37: KV Cache 避免重复计算
训练和推理用的是同一个 model(x) ,差别在于训练有 backward 和 optimizer.step,推理只有 forward 但要循环很多次。
Post-training 在代码层面和 pretraining 几乎一样,变的是数据 和loss 的定义 。
Serving 的 while 循环天然是串行的:生成 1000 个 token 就要跑 1000 次 forward,这就是推理优化的起点。
工程视角 / 真实世界
一个开源模型发布时,你拿到的就是:config.json(架构超参数)+ *.safetensors(权重)+ tokenizer.json(BPE 词表与合并规则)+ generation_config.json(默认采样参数)。这四样东西分别对应本课程的 Part 3、Part 1/4、第 06 课、第 17 课。
训练框架常见:Megatron-LM、DeepSpeed、PyTorch FSDP、TorchTitan;推理引擎常见:vLLM、SGLang、TensorRT-LLM。
真实公司里 AI Infra 岗位通常分为:训练 Infra(集群、并行、稳定性)、推理 Infra(引擎、调度、kernel、成本)、数据 Infra(数据管线)。附录 career-map 会展开。
💬 能聊的要点
「LLM 的训练分两大阶段:pretraining 用海量无标注文本做 next-token prediction,得到只会续写的 base model;post-training 用 SFT + 偏好/RL 把它变成助手或推理模型。算力绝大部分花在 pretraining。」
「训练是 compute-bound 的离线大作业,瓶颈在 FLOPs 和多卡通信;推理是在线服务,decode 阶段是 memory-bound 的,瓶颈在显存容量和带宽,所以 vLLM/SGLang 的核心优化都围绕 KV Cache 展开。」
「post-training 和 pretraining 在代码上几乎是同一个循环,区别在数据和目标函数:SFT 还是交叉熵,RLHF/GRPO 换成了奖励信号。」
常见误区
❌ ChatGPT 这类模型是直接在对话数据上训出来的 ✅ 先在网页/代码等海量文本上 pretrain,对话能力是 post-training 阶段「调」出来的,对话数据量远小于预训练数据量。
❌ 推理只是训练的「前向传播」,很简单 ✅ 推理的自回归逐 token 生成、并发请求、长度不一、显存受限,使它成为一个独立而复杂的系统工程问题。
❌ 模型越大越好,训练完就结束了 ✅ 对大多数公司而言,长期成本在 serving;推理效率直接决定每百万 token 的价格。
自测(心算/口答即可)
Base model 和 Instruct model 的区别是什么?分别是哪个阶段的产物?答案 Base model 是 pretraining 的产物,只会续写文档;Instruct model 是在 base model 上做 post-training(SFT + RLHF/DPO 等)得到的,会按指令回答。
「训练主要受算力限制,推理 decode 主要受显存带宽限制」——为什么会有这种差异?答案 训练时一次 forward 处理大量 token(大 batch × 长序列),每读一次权重能做很多计算;decode 时每个请求每步只生成 1 个 token,每读一遍全部权重只做很少的计算,所以卡在搬数据上。第 36 课会用 Roofline 细讲。
KV Cache 属于链路的哪一环?答案 Serving / Inference。训练时整段序列并行计算,不需要跨步缓存;推理时逐 token 生成,缓存历史 token 的 K/V 避免重复计算。
延伸
🎬 Karpathy:Deep Dive into LLMs like ChatGPT (3.5 小时,完整覆盖 pretraining 数据、tokenization、base model、SFT、RL、DeepSeek-R1)——本课的最佳配套视频
🎬 Karpathy:Intro to Large Language Models (1 小时,「两个文件」和「LLM OS」的比喻出自这里)
🎬 李沐:「跟李沐学AI」InstructGPT 论文精读(post-training 三步:SFT → Reward Model → PPO)
📄 Ouyang et al., 2022, Training language models to follow instructions with human feedback (InstructGPT)
📄 DeepSeek-AI, 2024, DeepSeek-V3 Technical Report (一份完整的「从预训练到部署」工程报告)
下一课 → 01 · Model = Function:Parameters、Loss 与拟合
01 · Model = Function:Parameters、Loss 与拟合
⏱ 10 分钟 | 前置:00 · The Big Picture | 难度:★☆☆
一句话
一个模型就是一个带着大量可调旋钮(Parameters )的函数 y = f(x; θ);Loss 是衡量「输出有多离谱」的一个数字;训练 就是自动地拧旋钮,让 Loss 在整个数据集上变小。
为什么要关心
「8B 模型」「权重文件 16GB」「loss 降到 2.1」「过拟合了」——这些说法全部建立在「模型 = 参数化函数 + 损失函数 + 优化」这一个框架上。这个框架想清楚了,后面 Transformer、MoE、RLHF 都只是在换「函数长什么样」或「loss 怎么定义」。
核心直觉
1. 从「写规则」到「搜参数」
传统编程:人写规则,机器执行。
input ──▶ [ 人写的 if/else ] ──▶ output
机器学习:人只定一个函数的形状 (架构)和一个打分标准 (loss),规则由数据「搜」出来。
input x ──▶ [ f(x; θ) ] ──▶ prediction ŷ ──┐
▲ ├─▶ Loss(ŷ, y) = 一个数字
│ │
└──── 调整 θ ◀────────────┘ (target y 来自数据)
符号约定(全课通用):
x:输入(一个房子的面积、一段文本……);y:真实答案(target,来自数据)
ŷ(读作 y-hat):模型的预测值,ŷ = f(x; θ)
θ(theta):模型全部参数 的统称,可以是 2 个数,也可以是 80 亿个数。分号 ; 的意思是「x 是每次调用传进来的输入,θ 是函数内部存着的状态」——类比一个带配置的服务:x 是请求,θ 是配置文件
Karpathy 在 2017 年的博文 Software 2.0 里就这么描述:神经网络是一种新型软件,它的「源代码」是数据集 + 架构,「编译」过程就是训练,「编译产物」就是权重。
2. 三个要素
Architecture(架构) :函数的形状。最简单的是一条直线 y = w·x + b,只有 2 个参数;GPT-2 small 是一个有约 124M 个参数的 Transformer;Llama 3 8B 约 80 亿个参数。架构决定了模型「能表达什么」 。
Parameters / Weights(参数) :函数里可调的数字,统称 θ。它们就是你下载到的 .safetensors 文件。8B 参数 × 2 字节(BF16,一种 16 位浮点格式,每个数占 2 字节,第 28 课)≈ 16GB——这就是「8B 模型的权重文件约 16GB」的来历。
Loss function(损失函数) :把「预测 vs 真实答案」压缩成一个标量(单个数字)。回归问题(预测一个连续数值,如房价)常用 MSE (Mean Squared Error,均方误差):L = mean((ŷ - y)²),意思是「每个样本的偏差先平方(去掉正负号、放大大错),再取平均」。语言模型用 Cross-Entropy (第 08 课):L = -log p(正确的下一个 token),意思是「你给正确答案的概率越低,罚得越重」。
MSE 代入数字走一遍(就是下面代码里的第一组数):真实值 y = [3.1, 4.9, 7.2, 8.8],参数瞎猜成 w=0.5, b=0 时预测 ŷ = 0.5x = [0.5, 1.0, 1.5, 2.0]:
偏差 ŷ - y : -2.6 -3.9 -5.7 -6.8
平方 : 6.76 15.21 32.49 46.24 → 求和 100.7 → 平均 ≈ 25.2
换成 w=2, b=1,偏差只剩 [-0.1, 0.1, -0.2, 0.2],MSE ≈ 0.025。同一份数据,换一组参数,loss 就变了 ——这就是下面说的「loss 是参数的函数」。
3. 「拟合」是什么意思
拿房价举例:数据是 (面积, 价格) 的点。你选的函数是直线,拟合就是找到一条让所有点的 MSE 最小的直线。
price
│ • ╱
│ • ╱ • ╱ = f(x; w, b)
│ • ╱ • • = 真实数据
│ • ╱•
│ ╱ •
└──────────────── area
要点:Loss 是参数的函数 ,不是输入的函数。推理 1→2→3:
训练时数据集是固定的,不会变;
唯一在变的是 θ,每换一组 θ 就能算出一个 loss 值;
所以 L(θ) 是参数空间里的一个「地形」:2 个参数(w, b)时,横轴 w、纵轴 b、高度是 loss,是一个三维曲面;80 亿参数时就是 80 亿维的地形。
训练 = 在这个地形里找低点(下一课:Gradient Descent)。
4. 拟合的两种失败
Underfitting(欠拟合) :函数太简单,表达不了数据的规律。用直线去拟合抛物线,参数怎么调都不行。对策:换更强的架构、更多参数。
Overfitting(过拟合) :函数太强、数据太少,模型把训练数据的噪声都背下来了,训练 loss 很低、新数据上很差。对策:更多数据、Regularization(正则化:给训练加约束、不让参数为了迎合个别样本而变得极端,第 09 课)。
类比:学生背答案 vs 学会方法。背答案的学生做原题满分(train loss 低),换一道新题就不会(validation loss 高)。
所以我们永远看两个 loss:train loss (训练集)和 validation loss (从数据里预留出来、从不参与训练 的验证集,相当于「没见过的新题」)。真正关心的是后者——它代表**泛化(Generalization)**能力,即在没见过的数据上也表现好。
代码片段
import torch
# 数据:y 大约等于 2x + 1,外加噪声
x = torch.tensor([1.0, 2.0, 3.0, 4.0])
y = torch.tensor([3.1, 4.9, 7.2, 8.8])
# 模型 = 带参数的函数;这里只有 2 个 parameters
w = torch.tensor(0.5)
b = torch.tensor(0.0)
def f(x):
return w * x + b
# Loss = 一个标量,衡量预测有多离谱
def mse(pred, target):
return ((pred - target) ** 2).mean()
print(mse(f(x), y)) # 参数瞎猜时 loss 很大,约 25.2
w, b = torch.tensor(2.0), torch.tensor(1.0)
print(mse(f(x), y)) # 参数接近真相时 loss 很小,约 0.025
模型本身(f)、参数(w, b)、损失(mse)是三个独立的东西,换任何一个都叫「改模型」,但含义完全不同。
这里是「人手动猜」参数;第 02 课会让机器用梯度自动找,第 09 课会让它在一个循环里自动跑。
把 w * x + b 换成一个 Transformer、把 4 个点换成数万亿个 token,这段代码的结构不变 。
工程视角 / 真实世界
打开任意 HuggingFace 模型页:config.json 描述架构 (层数、宽度、头数),model-0000x-of-0000y.safetensors 存参数 ,而 loss 不在发布物里——它只存在于训练代码中。
「参数量」直接决定了几件工程上的硬约束:权重显存(参数数 × 每参数字节数)、每生成一个 token 的计算量(约 2 × 参数量 FLOPs,第 04 课解释)、训练时的优化器状态(第 09 课)。
行业里的「模型规模」都指参数量:7B/8B、70B、405B;MoE 模型会写成「总参数 / 激活参数」,如 DeepSeek-V3 是 671B 总参数、每 token 激活 37B(第 21 课)。
💬 能聊的要点
「模型就是一个参数化函数,训练就是在参数空间里优化 loss;架构决定假设空间,数据和 loss 决定在里面找哪个点。」
「8B 模型 BF16 权重大约 16GB,这个换算——参数数 × 每参数字节数——是后面算显存、选 GPU、做量化的起点。」
「真正要看的是 validation loss 而不是 train loss,因为目标是泛化;LLM 预训练通常数据只过一遍左右,所以 train/val loss 往往贴得很近。」
常见误区
❌ 模型「存储」了训练数据 ✅ 模型存的是参数,数据的规律被压缩进参数;只有过拟合或高频重复的片段才可能被逐字记住。
❌ Loss 越低越好 ✅ 训练集 loss 可以低到接近 0 但验证集很差(过拟合),要看验证集 loss 和下游效果。
❌ 参数越多一定越好 ✅ 参数多需要足够的数据和算力匹配(第 18 课 Chinchilla),否则是浪费甚至过拟合。
自测(心算/口答即可)
一个 70B 参数的模型用 BF16 存,权重大约多大?用 INT4 呢?答案 BF16:70B × 2 字节 ≈ 140GB;INT4:70B × 0.5 字节 ≈ 35GB(实际略大,因为量化还要存 scale 等元数据)。
「Loss 是参数的函数」这句话怎么理解?答案 训练时数据是固定的,唯一在变的是参数 θ;每换一组 θ 就得到一个 loss 值,所以 L(θ) 是参数空间上的一个曲面,训练就是在这个曲面上找低点。
训练 loss 持续下降,验证 loss 先降后升,说明什么?答案 开始过拟合:模型在记训练集的细节/噪声,泛化能力变差。应该早停(early stopping)、加数据或加正则。
延伸
🎬 Karpathy:The spelled-out intro to neural networks and backpropagation: building micrograd (后半段用 MSE loss 训练一个小 MLP,就是这一课框架的最小完整实例)
🎬 李沐:《动手学深度学习》第 3 章「线性神经网络」(3.1 线性回归)、第 4 章中「模型选择、欠拟合和过拟合」一节
📄 Andrej Karpathy, Software 2.0 (2017 博文)
下一课 → 02 · Gradient Descent:沿着坡往下走
02 · Gradient Descent:沿着坡往下走
⏱ 10 分钟 | 前置:01 · Model = Function | 难度:★☆☆
一句话
**Gradient(梯度)**告诉你「每个参数往哪个方向动一点,loss 上升最快」;**Gradient Descent(梯度下降)**就是反复朝梯度的反方向迈一小步:θ ← θ - lr × ∇L(θ)。
为什么要关心
上一课说训练就是在 80 亿维的地形里找低点,但你不可能枚举或随机试——维度太高了。梯度下降是今天几乎所有神经网络训练的唯一实用方法;Adam、AdamW、学习率调度、梯度裁剪、梯度累积这些名词,全都是在这一行公式上的改良或工程补丁。
核心直觉
1. 蒙眼下山
想象你蒙着眼站在山坡上,想走到谷底。你看不见全貌,但可以用脚感受脚下的坡度 。策略:感受坡往哪边最陡地向上,就朝反方向迈一步,然后再感受、再迈步。
loss
│ ● ● = 当前参数位置
│ ╲ 每一步:看斜率,往下挪
│ ●
│ ╲
│ ●
│ ╲___●___╱ ← 谷底:斜率 ≈ 0,停下
└──────────────────────── θ
坡度 = derivative(导数)/ gradient(梯度)
步长 = Learning Rate (学习率,lr)
2. 导数就是「轻推一下,输出变多少」
Karpathy 的 micrograd 视频就是从这里开始的:f(x) = 3x² - 4x + 5,在 x = 3 处把 x 加一个很小的 h = 0.0001,看 f 变了多少,再除以 h:
(f(3 + h) - f(3)) / h ≈ 14
这就是导数的定义:输入轻推一点,输出按多少倍跟着动 。解析解 f'(x) = 6x - 4 = 14 验证一致。导数为正,说明 x 变大会让 f 变大——想让 f 变小就该让 x 变小。
多个参数时,对每个参数各求一个 Partial derivative(偏导数,记作 ∂L/∂w:只动 w、其他参数全部冻住时,L 的变化倍数) ,拼成一个向量,就是 Gradient ∇L (∇ 读作 nabla,「对所有参数求导」的简写)。它是一个和参数形状完全一样的 Tensor(张量,即多维数组):8B 参数的模型,梯度也有 8B 个数。
🧩 难点拆解:Gradient 到底告诉了你什么
先直觉 :梯度就是每个参数一个「敏感度读数」。符号告诉你方向(正 = 这个参数调大会让 loss 变大),绝对值告诉你有多敏感。更新规则 θ ← θ - lr × ∇L 里的减号 就是「反着读数走」:敏感度为正就调小,为负就调大,越敏感调得越多。
再代入数字 :沿用上一课的直线拟合,参数瞎猜为 w=0.5, b=0,此时 MSE ≈ 25.2。
算梯度:∂L/∂w ≈ -27.35,∂L/∂b = -9.5(可以用「轻推」法验证:w 从 0.5 推到 0.501,loss 下降约 0.027,比值 ≈ -27.3)
读数:两个都是负数 → 调大 w 和 b 都能让 loss 下降。这和常识一致:当前预测 [0.5, 1, 1.5, 2] 全部低于真实值 [3.1, 4.9, 7.2, 8.8],当然该往上调
w 的读数绝对值更大 → loss 对 w 更敏感(因为 w 要乘上 x,x 最大到 4)
取 lr = 0.01 走一步:w = 0.5 - 0.01 × (-27.35) = 0.7735,b = 0 - 0.01 × (-9.5) = 0.095
新的 loss ≈ 17.5,一步就从 25.2 降下来了。重复这个过程就是训练
一句话总结 :梯度 = 「每个参数对 loss 的影响力读数」,梯度下降 = 「每个参数按自己的读数反向挪一小步」,所有参数同时挪。
3. Learning Rate:最重要的超参数
lr 太小:●→●→●→●→●→ ...(走得太慢,训练预算用完还没到)
lr 合适:●──▶●──▶●─▶● 谷底
lr 太大:●────────▶ ◀────────● 来回震荡,甚至 loss 爆炸成 NaN
梯度只是局部 信息:它只保证「在附近」朝这个方向走会下降。步子太大就会越过谷底跑到对面坡上。还是上面那个 4 个点的直线拟合,从同一个起点跑 50 步:
lr
第 1 步后 loss
第 50 步后 loss
现象
0.001
24.3
4.7
太慢,还远没到底
0.01
17.5
0.064
稳步下降
0.1
11.3
0.023
更快(这个问题里还扛得住)
0.15
56.9
≈ 1.4e19
越过谷底、越跳越高,发散
注意 0.1 和 0.15 只差一点,结果天壤之别——稳定的 lr 有一个「悬崖」。大模型的地形更复杂,所以 lr 通常很小,LLM 预训练常见的峰值 lr 在 1e-4 ~ 6e-4 量级(如 Llama 2 7B 用 3e-4),而且会随训练变化(第 09 课:warmup + cosine decay)。
4. 从 GD 到 SGD:别每次都看全部数据
严格的 Gradient Descent 要在整个数据集 上算 loss 再求梯度。数据有万亿 token 时这不可能。于是用 Stochastic Gradient Descent(SGD)/ Mini-batch SGD :每步随机抽一小批(batch)数据,用这批数据的梯度近似 全量梯度。类比:想知道全表某列的平均值,不做全表扫描,而是随机抽样几千行估计——估计有误差,但便宜得多,而且平均来看不偏(统计上叫「无偏估计」)。
好处:每步很便宜,可以走很多步;梯度里的噪声还有一定的正则化作用。
代价:方向有噪声,loss 曲线会抖。batch 越大,梯度估计越准,但每步越贵。
今天说「SGD」时,几乎总是指 mini-batch SGD。
5. 高维地形并不可怕
直觉上担心「会困在局部最小值」(一个小坑,四周都比它高,但不是全局最低点)。但在上亿维的空间里,一个点要在所有 方向上都是谷底非常难,更常见的是 **saddle point(鞍点:像马鞍中心,有的方向往上、有的方向往下,梯度接近 0)**和大片平坦区域;而大模型的实践经验是:各种不同初始化最终都能收敛到 loss 相近的解。真正的工程难点反而是 lr 选不好导致的不稳定(loss spike :训练中 loss 突然跳高)。
代码片段
# 1) 数值求导:micrograd 视频的第一个例子
def f(x):
return 3*x**2 - 4*x + 5
h = 0.0001
x = 3.0
print((f(x + h) - f(x)) / h) # ≈ 14.0003,斜率为正
# 2) 用梯度下降拟合上一课的直线 y ≈ 2x + 1
xs, ys = [1, 2, 3, 4], [3.1, 4.9, 7.2, 8.8]
w, b, lr = 0.5, 0.0, 0.01
for step in range(2000):
# MSE 对 w、b 的解析梯度(下一课会自动算)
dw = sum(2*(w*x + b - y)*x for x, y in zip(xs, ys)) / len(xs)
db = sum(2*(w*x + b - y) for x, y in zip(xs, ys)) / len(xs)
w -= lr * dw # 核心一行:朝梯度反方向走一步
b -= lr * db
print(w, b) # 收敛到 w≈1.94, b≈1.15 附近
数值求导(加 h 再除以 h)直观但慢:N 个参数就要跑 N+1 次 forward,8B 参数完全不可行——这就是为什么需要下一课的 Backpropagation ,一次 backward 就算出全部梯度。
dw、db 两行是手推的导数:单个样本的 loss 是 (wx + b - y)²,对 w 求导得 2(wx + b - y)·x,对 b 求导得 2(wx + b - y),再对 4 个样本取平均。手推只适用于这种小公式,下一课会让机器自动推。
w -= lr * dw 就是所有优化器的祖宗。PyTorch 里 optimizer.step() 做的就是这一行(加上各种改良)。
注意收敛值不是精确的 2 和 1:数据有噪声,梯度下降找到的是让这 4 个点 MSE 最小 的直线。
工程视角 / 真实世界
PyTorch 中最朴素的形式:torch.optim.SGD(model.parameters(), lr=0.01)。LLM 基本不用纯 SGD,而是 AdamW (第 09 课),它为每个参数自适应调整步长,代价是每个参数多存 2 个状态。
训练监控面板上最常看的三条曲线:loss 、lr 、grad norm (梯度的整体大小)。grad norm 突然飙升往往预示 loss spike,所以大模型训练普遍做 gradient clipping (梯度裁剪,如把全局 norm 限制在 1.0,Llama 2 就是这么设的)。
「batch size」在分布式训练里等于「所有 GPU 上的样本数之和」。数据并行(第 29 课)本质上就是:每张卡算一部分 batch 的梯度,再 all-reduce(集合通信操作:所有卡把各自的数加起来,并且每张卡都拿到结果)求平均,然后大家执行同一个 θ -= lr × grad。
💬 能聊的要点
「梯度下降的核心就一行 θ -= lr * grad,梯度只提供局部方向,所以 lr 决定了稳定性;大模型训练的很多工程手段——warmup、梯度裁剪、AdamW——都是为了让这一步走得稳。」
「实际用的是 mini-batch SGD:用一小批样本的梯度近似全量梯度,用噪声换速度;batch size 和 lr 需要一起调。」
「数值求导要跑 N 次 forward,而 backprop 只需要一次 forward 加一次 backward,这是深度学习能扩展到十亿参数的前提。」
常见误区
❌ 梯度指向 loss 下降的方向 ✅ 梯度指向上升最快 的方向,所以更新时要减去 梯度。
❌ lr 越大训练越快 ✅ 超过临界值会震荡或发散(loss 变 NaN);lr 太小则浪费算力。
❌ 深度学习最大的问题是陷入局部最小值 ✅ 在超高维下更常见的是鞍点和平坦区;大模型的实际痛点是训练不稳定(loss spike)和超参数选择。
自测(心算/口答即可)
当前参数 w = 1.0,dL/dw = 4,lr = 0.1,更新后 w 是多少?答案 w = 1.0 - 0.1 × 4 = 0.6。
一个 8B 参数的模型,梯度张量有多少个数?答案 也是 8B 个——梯度与参数一一对应、形状相同。BF16 存储约 16GB,这也是训练比推理费显存的原因之一。
为什么大模型训练用 mini-batch 而不是全量数据算梯度?答案 全量数据有万亿 token,算一次梯度的成本无法接受;mini-batch 梯度是全量梯度的无偏估计,每步便宜得多,能在同样算力下走更多步。
延伸
🎬 Karpathy:The spelled-out intro to neural networks and backpropagation: building micrograd (开头的「导数直觉」部分,用 3x² - 4x + 5 做数值求导;结尾手写梯度下降训练循环)
🎬 李沐:《动手学深度学习》第 11 章「优化算法」(梯度下降、随机梯度下降、小批量随机梯度下降三节)
📄 Sebastian Ruder, 2016, An overview of gradient descent optimization algorithms (arXiv:1609.04747,各种优化器的综述)
下一课 → 03 · Backpropagation 与 Computational Graph
03 · Backpropagation 与 Computational Graph:micrograd 的核心思想
⏱ 10 分钟 | 前置:02 · Gradient Descent | 难度:★★☆
一句话
把整个计算记录成一张 Computational Graph(计算图) ,然后从 loss 出发反向 遍历这张图,每个节点只用 Chain Rule(链式法则)做一次局部乘法,就能一次性算出 loss 对 所有 参数的梯度——这就是 Backpropagation(反向传播) 。
为什么要关心
上一课的数值求导对 N 个参数要跑约 N+1 次 forward;backprop 只要一次 forward + 一次 backward ,两者合计约为单次 forward 的 3 倍(backward 本身约 2 倍,原因见下文),这个倍数与参数量无关。这是深度学习能扩展到千亿参数的根本原因。对 Infra 而言,它还解释了两个关键事实:训练 FLOPs ≈ 6 × 参数量 × token 数 ,以及训练要存 activations,所以比推理费显存得多 。
核心直觉
1. 计算图:把表达式拆成小积木
Karpathy 在 micrograd 视频里用的例子:
a = 2.0 ─┐
├─(*)── e = -6.0 ─┐
b = -3.0 ┘ ├─(+)── d = 4.0 ─┐
c = 10.0 ───────────────────┘ ├─(*)── L = -8.0
f = -2.0 ─────────────────────────────────────┘
即 L = (a*b + c) * f。Forward :从左往右算,e = 2 × (-3) = -6,d = -6 + 10 = 4,L = 4 × (-2) = -8。每个节点记住「我是由谁、通过什么运算得到的」——就像构建系统(Make/Bazel)里每个产物记住自己的依赖。
2. Backward:从右往左,传递「责任」
问题:L 对 a 的梯度是多少?即「a 轻推一点,L 变多少倍」?
Chain Rule 的大白话:如果 a 影响 e 的倍数是 x,e 影响 L 的倍数是 y,那 a 影响 L 的倍数就是 x × y。
从 L 往回走,每个节点只需要知道两件事:①上游传回来的梯度 out.grad;②自己这一步运算的局部导数 。
节点
局部导数
梯度 = 上游梯度 × 局部导数
L
—
dL/dL = 1
d, f
L = d*f → ∂L/∂d = f, ∂L/∂f = d
d.grad = -2, f.grad = 4
e, c
d = e+c → 局部导数都是 1
e.grad = -2, c.grad = -2
a, b
e = a*b → ∂e/∂a = b, ∂e/∂b = a
a.grad = -2×(-3) = 6 , b.grad = -2×2 = -4
规律很简单:
+ 节点:把上游梯度原样分发 给两个输入(像路由器)
* 节点:把上游梯度乘以另一个输入的值 再发回去(像交换器)
每种运算(加、乘、tanh、exp、matmul……)只需要写一次自己的局部导数,任意复杂的网络都能自动求导。这就是 Autograd(自动微分) 。
🧩 难点拆解:Chain Rule 为什么是「相乘」
先直觉 :把每个节点想成一组齿轮。a 转 1 圈带动 e 转 x 圈,e 转 1 圈带动 L 转 y 圈,那 a 转 1 圈,L 就转 x × y 圈。局部导数就是每一对齿轮的「传动比」,Chain Rule 就是把路径上的传动比连乘。(类比不成立的地方:真实齿轮的传动比是固定的,而网络的局部导数随当前数值变化——比如 e = a*b 的传动比等于 b 的当前值——所以梯度只在「当前这组参数附近」有效,每步都要重算。)
再用数字验证 (上面的图,a=2, b=-3, c=10, f=-2):
a → e 的传动比:e = a × b,a 每加 1,e 变化 b = -3 倍
e → d 的传动比:d = e + c,1 倍(加法原样传递)
d → L 的传动比:L = d × f,f = -2 倍
连乘:(-3) × 1 × (-2) = 6,这就是表里的 a.grad = 6
用上一课的「轻推」法核对:a 从 2 推到 2.001 → e = -6.003 → d = 3.997 → L = -7.994。L 变了 +0.006,除以 0.001 正好是 6 ✓
为什么从后往前算 :注意第 2、3 步(e → L 的传动比 = -2)对 a 和 b 是共用的 。从 L 出发往回走,先把「e 对 L 的影响 = -2」算好存在 e.grad 里,a 和 b 各自再乘一次自己的局部导数即可,不用重复走 e 之后的路径。真实网络里「共用的后半段」是几十层,参数有几十亿个,这种复用就是 backprop 快的全部秘密——本质上是动态规划:把「从这里到 loss 的影响」作为子问题结果缓存起来。
一句话总结 :一个参数的梯度 = 它到 loss 路径上所有局部导数的乘积(多条路径则相加);反向走可以让所有参数共用下游已经算好的那一段。
3. 为什么是「反向」
一个 loss,上亿个参数:一个输出、很多输入 。从输出往回走一遍,每条边只经过一次,就得到所有输入的梯度。(反之,如果从输入往前推,每个输入都要推一遍。)所以 backward 的代价与 forward 是同一个量级。
为什么 backward 约是 forward 的 2 倍 ?网络里绝大部分计算是矩阵乘法 y = W x(第 04 课)。forward 时它是 1 次矩阵乘;backward 时要回答两个 问题,各是 1 次同样大小的矩阵乘:
梯度对 x 是多少?(要继续往更前面的层传)→ 1 次矩阵乘
梯度对 W 是多少?(这是参数,要拿去更新)→ 1 次矩阵乘
所以 backward ≈ 2 × forward。FLOPs (Floating Point Operations,浮点运算次数)是衡量计算量的单位,一次乘法 + 一次加法算 2 FLOPs。于是:
forward:约 2N FLOPs / token(N 为参数量,每个参数参与一次乘加)
backward:约 4N FLOPs / token
训练合计:约 6N FLOPs / token → 训练总算力 ≈ 6 × N × D(D 为 token 数)
例:8B 模型训练 15T tokens ≈ 6 × 8e9 × 15e12 ≈ 7.2e23 FLOPs。这个公式在第 18 课 Scaling Laws 和算 GPU 预算时会反复出现。
4. 代价:forward 的中间结果要留着
注意 * 节点的 backward 需要另一个输入的值 (算 a.grad 要用到 b = -3)。所以 forward 时算出的中间结果——统称 activations (每一层的输出张量,比如上图的 e、d)——必须一直存到 backward 用完为止。推理不需要 backward,算完一层就可以丢;训练则要为每一层保留 activations,它和 batch size、序列长度成正比,常常比权重本身还大。
工程上的应对是 Activation Checkpointing / Recomputation :只存一部分 activations,backward 时重新算一遍 forward 补回来——用约多 1/3 的计算换大量显存。
代码片段
micrograd 的精简版(原仓库的核心 engine 约 100 行):
class Value: # 一个标量 + 它在计算图里的位置
def __init__(self, data, _children=()):
self.data = data
self.grad = 0.0 # dL/d(self),初始为 0
self._backward = lambda: None # 如何把梯度传给 children
self._prev = set(_children) # 计算图的边
def __mul__(self, other):
out = Value(self.data * other.data, (self, other))
def _backward():
self.grad += other.data * out.grad # 局部导数 = 另一个输入
other.grad += self.data * out.grad # 注意是 +=,不是 =
out._backward = _backward
return out
def backward(self):
topo, seen = [], set()
def build(v): # topological sort:保证先处理下游
if v not in seen:
seen.add(v)
for child in v._prev: build(child)
topo.append(v)
build(self)
self.grad = 1.0 # dL/dL = 1
for v in reversed(topo): # 从 loss 往回走
v._backward()
+= 是 Karpathy 在视频里专门讲的 bug:同一个变量被用了两次(如 b = a + a),两条路径的梯度要累加 ;写成 = 就会覆盖掉一条路径。这也是 PyTorch 里要调用 optimizer.zero_grad() 的原因——梯度默认是累加的。
backward() 先做拓扑排序(和构建系统排编译顺序是同一个算法:依赖在前、使用者在后),再逆序调用每个节点的 _backward,保证处理一个节点时,它所有下游(使用了它的节点)的梯度都已汇总完毕。
PyTorch 的 autograd 思想完全一样,只是节点从标量 Value 变成了 Tensor,运算从 * 变成了 matmul、softmax 等,并且在 GPU 上执行。
工程视角 / 真实世界
PyTorch 里 loss.backward() 就是上面的 backward();每个 Tensor 的 .grad_fn 就是 micrograd 里的 _backward,requires_grad=True 表示「这个节点要进计算图」。
推理时写 with torch.no_grad(): 或 torch.inference_mode(),就是告诉框架不要建计算图、不要存 activations ,显存和开销都会显著下降。vLLM/SGLang 的 forward 都跑在 inference mode 下。
训练显存的大头:权重 + 梯度 + 优化器状态(第 09 课)+ activations。前三项靠 ZeRO/FSDP 切分(第 29 课),activations 靠 checkpointing 和序列并行(第 30 课)。
「梯度累积(gradient accumulation)」之所以能工作,正是利用了梯度默认 += 的特性:多次 backward 不清零,再 step 一次,等效于更大的 batch。
💬 能聊的要点
「Backprop 就是在计算图上反向应用链式法则:每个算子只实现自己的局部导数,一次 backward 就拿到所有参数的梯度,代价约为 forward 的 2 倍,所以训练约 6N FLOPs/token,推理约 2N。」
「训练比推理费显存,除了梯度和优化器状态,还因为 backward 需要 forward 的中间 activations;activation checkpointing 用重算换显存。」
「PyTorch 的梯度是累加的,这既是要 zero_grad 的原因,也是 gradient accumulation 能用小 batch 模拟大 batch 的原因。」
常见误区
❌ Backprop 是一种优化算法 ✅ Backprop 只负责算梯度 ;用梯度更新参数的是优化器(SGD、AdamW)。
❌ 反向传播比前向传播便宜 ✅ backward 约为 forward 的 2 倍 FLOPs,而且还要读取 forward 存下的 activations。
❌ 推理时也需要存所有中间结果 ✅ 推理不做 backward,层与层之间的 activations 用完即弃;推理要长期保留的是 KV Cache(第 37 课),那是为了「生成下一个 token」而不是为了求梯度。
自测(心算/口答即可)
L = (a + b) * c,a=1, b=2, c=4。求 a.grad 和 c.grad。答案 设 d = a + b = 3,L = d × c = 12。c.grad = d = 3;d.grad = c = 4;+ 原样分发,a.grad = 4。
一个 70B 模型训练 2T tokens,训练总 FLOPs 大约多少?答案 6 × 70e9 × 2e12 = 8.4e23 FLOPs。
为什么 b = a + a 时,micrograd 必须用 += 累加梯度?答案 a 通过两条路径影响 b,多元链式法则要求把各条路径的贡献相加:db/da = 1 + 1 = 2。用 = 会让第二次写入覆盖第一次,得到错误的 1。
延伸
🎬 Karpathy:The spelled-out intro to neural networks and backpropagation: building micrograd (整集都在讲本课;a*b + c 的手动反向传播、+= bug、拓扑排序都在里面)
🎬 Karpathy:Building makemore Part 4: Becoming a Backprop Ninja (在 Tensor 级别手写 backward,适合想深挖的人)
🎬 李沐:《动手学深度学习》2.5「自动微分」、4.7「前向传播、反向传播和计算图」
📄 Rumelhart, Hinton & Williams, 1986, Learning representations by back-propagating errors (Nature)
💻 github.com/karpathy/micrograd
下一课 → 04 · Neural Network:Linear 层 + 非线性 Activation
04 · Neural Network:Linear 层 + 非线性 Activation
⏱ 10 分钟 | 前置:03 · Backpropagation 与 Computational Graph | 难度:★☆☆
一句话
神经网络就是 Linear 层(矩阵乘法 y = Wx + b) 和 非线性 Activation(ReLU / GELU 等) 交替堆叠;Linear 负责「混合信息」,Activation 负责「让堆叠有意义」,而计算量几乎全在矩阵乘法里。
为什么要关心
Transformer 每层 2/3 甚至更多的参数在 FFN(第 15 课;GPT-2 约 2/3,Llama 3 8B 约 80%),FFN 就是一个两层 MLP;Attention 的 Q/K/V 投影也是 Linear 层。理解了「网络 = 一堆 matmul + 逐元素非线性」,你就理解了为什么 GPU 的核心是 Tensor Core(专做矩阵乘) 、为什么推理 FLOPs ≈ 2 × 参数量、为什么量化主要量化 Linear 层的权重。
核心直觉
1. 一个神经元
micrograd 里的 Neuron:输入 x₁…xₙ,各乘一个权重,加起来,加 bias,过一个非线性函数:
x1 ──w1──┐
x2 ──w2──┼──▶ Σ(wᵢxᵢ) + b ──▶ tanh ──▶ out
x3 ──w3──┘
大白话:加权投票,再做一个「软开关」 。权重 wᵢ 决定每个输入有多重要;bias (偏置 b)是一个与输入无关的常数项,相当于「投票门槛」的平移;非线性函数(这里是 tanh ,把任意实数平滑地压到 -1 到 1 之间)决定输出何时「激活」。
2. 一层 = 很多神经元 = 一次矩阵乘法
把 d_out 个神经元并排,它们的权重拼起来就是矩阵 W(形状 d_out × d_in):
y = W x + b —— 大白话:输出的每一维都是输入各维的一个加权和。
d_in / d_out 是输入 / 输出向量的长度(维度)。代入小数字:d_in = 3, d_out = 2,
W (2×3) x (3) b (2) y (2)
[ 1 0 2 ] [ 1 ] [ 0.5 ] [ 1·1 + 0·2 + 2·3 + 0.5 ] [ 7.5 ]
[ 0 -1 1 ] × [ 2 ] + [ 0 ] = [ 0·1 - 1·2 + 1·3 + 0 ] = [ 1 ]
[ 3 ]
W 的每一行就是一个神经元的权重;y 的每一维是一个神经元的输出。
参数量:d_in × d_out + d_out(上例:6 个权重 + 2 个 bias = 8)
计算量:每个权重一次乘 + 一次加 → 约 2 × d_in × d_out FLOPs / 样本(上例:约 12)
整个网络的参数绝大部分在这些 W 里,所以每处理 1 个 token,forward 约 2 × 参数量 FLOPs ——上一课 2N 的来源。
3. 为什么必须有非线性
两个 Linear 叠起来:W₂(W₁x) = (W₂W₁)x,还是一个 Linear。叠 100 层也等价于 1 层,表达能力没有任何提升。
用数字看:W₁ = 3, W₂ = 2(都是 1×1),2 × (3x) = 6x,两层等于一层 W = 6。
在中间插入非线性 σ:W₂ σ(W₁x),就再也化简不了。直觉上,每个 ReLU(max(0, x),负数变 0、正数不变)在输入空间里「折一刀」。例:ReLU(x) + ReLU(-x) = |x|——两个 ReLU 神经元就拼出了一个 V 形折线,这是任何单个线性函数都做不到的。足够多的折线可以拼出任意形状的曲线——这就是 Universal Approximation(万能近似)定理 的直观含义:只要够宽,两层 MLP 能逼近任意连续函数。(能表达 ≠ 能学到,深度和架构依然重要。)
4. 常见 Activation 对比
Activation
公式(直觉)
特点
谁在用
tanh
压到 (-1, 1)
两端饱和(输入很大/很小时输出几乎不变,导数 ≈ 0,梯度传不回去,叫「梯度消失」)
micrograd、早期 RNN
ReLU
max(0, x)
简单、快;负半轴梯度为 0(「死神经元」)
CNN 时代的默认
GELU
平滑版 ReLU
在 0 附近平滑、负值有小输出
BERT、GPT-2
SiLU / Swish
x · sigmoid(x)(sigmoid 把实数压到 0~1)
与 GELU 相近
Llama 系列(配合 SwiGLU,第 15 课)
大模型时代的趋势:从 ReLU → GELU → SwiGLU (带门控的变体)。区别在第 15 课展开,这里只需记住它们都是「逐元素(element-wise)的非线性」,计算量相对 matmul 可以忽略,但会占用显存带宽。
5. MLP:最基础的网络
x (d=4096) ─▶ Linear(4096→14336) ─▶ SiLU ─▶ Linear(14336→4096) ─▶ y
(升维:展开特征) (降维:压回来)
MLP (Multi-Layer Perceptron,多层感知机)= 若干 Linear 层中间夹 Activation。这就是 Transformer 里 FFN (Feed-Forward Network,每层 Transformer 里的那个 MLP 子模块)的骨架(Llama 3 8B 的 hidden size 是 4096、FFN 中间维度正是 14336,外加一个门控分支)。「先升维再降维」可以理解为:展开到高维空间里做非线性变换,再投影回来。
代码片段
import torch
import torch.nn as nn
class MLP(nn.Module):
def __init__(self, d_in, d_hidden, d_out):
super().__init__()
self.fc1 = nn.Linear(d_in, d_hidden) # W1: d_hidden × d_in
self.act = nn.GELU() # element-wise 非线性
self.fc2 = nn.Linear(d_hidden, d_out) # W2: d_out × d_hidden
def forward(self, x): # x: (batch, d_in)
return self.fc2(self.act(self.fc1(x)))
mlp = MLP(768, 3072, 768) # GPT-2 small 的 FFN 尺寸
n_params = sum(p.numel() for p in mlp.parameters())
print(n_params) # 4,722,432 ≈ 4.7M
x = torch.randn(32, 768) # 32 个 token 同时算
y = mlp(x) # 一次 (32×768)@(768×3072) 等 matmul
nn.Linear 内部就是 x @ W.T + b;多个 token 堆成 batch 后,一次 matmul 同时处理,这正是 GPU 擅长的。
GPT-2 small 每层 FFN 约 4.7M 参数,12 层合计约 57M,占 124M 总参数的近一半(Embedding 占约 39M,见第 07 课)。
把 nn.GELU() 删掉,这个 MLP 就退化成一个线性变换,训练多久都学不会非线性规律。
工程视角 / 真实世界
矩阵乘法在 GPU 上叫 GEMM (General Matrix Multiply),由 cuBLAS / CUTLASS 等库实现,跑在 Tensor Core 上。(m×k) @ (k×n) 的 FLOPs 是 2mkn。H100 SXM 的 BF16 Tensor Core 稠密算力约 989 TFLOPS(TFLOPS = 每秒万亿次浮点运算),而普通 CUDA core 低一个数量级——所以网络设计会尽量把计算变成大 matmul。
Kernel Fusion :kernel 是在 GPU 上执行的一个函数(一次 GPU 调用)。Activation 这种逐元素运算计算量小,但如果单独一个 kernel,要把整个张量从显存读出再写回,受带宽限制。实践中常把 bias + activation 融合进 matmul 的尾部(epilogue),省一次读写。这个思想在 FlashAttention(第 40 课)里发挥到极致。
量化(第 43 课)主要针对 Linear 层的 W:因为参数都在这里,推理时读权重的带宽也都花在这里。
💬 能聊的要点
「神经网络就是 Linear 和非线性交替堆叠:没有非线性,多层会塌缩成一层;Linear 占了几乎全部参数和 FLOPs,所以一个 dense 模型 forward 约 2N FLOPs/token。」
「Transformer 的 FFN 就是一个先升维再降维的 MLP,激活函数从 ReLU 演化到 GELU 再到 SwiGLU;FFN 通常占每层参数的 2/3 以上。」
「逐元素算子 FLOPs 很少但吃带宽,所以工程上要把它们和 GEMM 做 kernel fusion。」
常见误区
❌ 层数越多表达能力越强,哪怕全是 Linear ✅ 纯 Linear 叠多少层都等价于一层,非线性是深度有意义的前提。
❌ Activation 函数是计算瓶颈 ✅ FLOPs 瓶颈在 matmul;Activation 的问题是额外的显存读写,靠 fusion 解决。
❌ 「神经元」是某种生物模拟的复杂单元 ✅ 工程上它就是矩阵的一行加一个逐元素函数,理解成「矩阵乘法 + 非线性」更准确。
自测(心算/口答即可)
nn.Linear(4096, 4096, bias=False) 有多少参数?处理 1 个 token 约多少 FLOPs?答案 4096² ≈ 16.8M 参数;约 2 × 16.8M ≈ 33.6M FLOPs。
为什么去掉非线性后两层 MLP 学不会 XOR?答案 去掉非线性后整个网络等价于一个线性函数,只能画一条直线(超平面)分割输入空间,而 XOR 不是线性可分的。
(512×4096) @ (4096×14336) 这次 matmul 的 FLOPs 大约是多少?答案 2 × 512 × 4096 × 14336 ≈ 6.0e10,即约 60 GFLOPs。
延伸
🎬 Karpathy:The spelled-out intro to neural networks and backpropagation: building micrograd (后半段用 Neuron → Layer → MLP 三个类搭出一个小网络)
🎬 李沐:《动手学深度学习》第 4 章「多层感知机」(4.1 多层感知机,含 ReLU/sigmoid/tanh 对比)
📄 Hendrycks & Gimpel, 2016, Gaussian Error Linear Units (GELUs) (arXiv:1606.08415)
下一课 → 05 · Language Model 的本质:Next-Token Prediction
05 · Language Model 的本质:Next-Token Prediction(从 Bigram 到 MLP)
⏱ 10 分钟 | 前置:04 · Neural Network | 难度:★★☆
一句话
**Language Model(语言模型)**只做一件事:给定前文,输出「下一个 token」的概率分布 P(x_t | x_<t);从数 bigram 次数,到 MLP,再到 Transformer,变的只是「怎么利用前文」,这个目标本身从未改变。
为什么要关心
ChatGPT 的对话、写代码、推理,底层全都是「一次预测一个 token,然后把它接到输入后面再预测」。理解了这一点,你就理解了:为什么生成是串行的(推理优化的根源)、为什么训练可以高度并行、为什么 base model 只会「续写」、为什么上下文长度是硬约束。
核心直觉
1. 把「文本的概率」拆成一连串「下一个 token 的概率」
P(x₁, x₂, …, x_T) = P(x₁) · P(x₂|x₁) · P(x₃|x₁,x₂) · … · P(x_T|x_<T)
符号:x_t 是第 t 个 token;x_<t 是「第 t 个之前的所有 token」;P(A | B) 读作「已知 B 时 A 的概率」。
大白话:一句话出现的概率 = 每个词在「看过前面所有词」的条件下出现的概率,连乘起来。这是概率论的链式法则,不是近似。
代入数字:名字 an,按下文 makemore 的 bigram 统计(. 表示开始/结束),
P(. a n .) = P(a | .) × P(n | a) × P(. | n)
≈ 0.14 × 0.16 × 0.37 ≈ 0.0083
每一项都是「看着前文,猜下一个」。所以只要能预测下一个 token,就能给任何文本打分,也能生成文本。这种一次生成一个、依赖已生成内容的方式叫 Autoregressive(自回归) 。
2. makemore 的故事线:生成「像名字的名字」
Karpathy 的 makemore 用一个 32,033 个英文名字的数据集(names.txt),逐字符(26 个字母 + 1 个表示开始/结束的 .,共 27 个 token)学习生成新名字。
第一步:Bigram(只看前 1 个字符)
统计每对相邻字符出现的次数,得到一张 27 × 27 的计数表,每行归一化就是 P(下一个 | 当前)。
下一个字符 →
. a b ... n ...
当前 . 0 4410 1306 ...
字符 a 6640 556 541 ... 5438
↓ ...
生成时:从 . 出发,按当前行的概率抽样下一个字符,重复直到抽到 .。效果很差(生成的像乱码名字),因为只看 1 个字符的上下文太少。用 **NLL(Negative Log-Likelihood,负对数似然,即 cross-entropy loss,第 08 课细讲)**衡量:对每个位置取 -ln P(真实的下一个字符),再取平均,越低越好。上面 an 的例子:-(ln 0.14 + ln 0.16 + ln 0.37) / 3 ≈ (1.97 + 1.83 + 0.99) / 3 ≈ 1.60。bigram 在整个训练集上平均约 2.45 ;作为对比,完全随机猜(27 选 1,每个字符概率都是 1/27)是 -ln(1/27) = ln 27 ≈ 3.30。
第二步:把 bigram 变成神经网络
一个惊人的对照:把当前字符编码成 one-hot 向量(长度 27,只有该字符的位置是 1、其余全 0),乘一个 27×27 的权重矩阵得到 27 个 logits (原始打分),再过 softmax (把打分变成加起来为 1 的概率,第 08 课),用梯度下降去训,最终 loss 也收敛到约 2.45——和数数得到的完全一样 。意义在于:计数表没法扩展到更长的上下文(看前 3 个字符就要 27³ ≈ 2 万行,看前 10 个就爆炸),而神经网络可以。
第三步:MLP(看前 3 个字符)
makemore Part 2 复现 Bengio 等人 2003 年的 A Neural Probabilistic Language Model :把前 3 个字符各查一个 Embedding 向量(第 07 课)拼起来,过一个 MLP,输出 27 维 logits。loss 降到约 2.1–2.2 区间,生成的名字明显更像样。
第四步及以后 :WaveNet 式层级结构 → Transformer(Part 2)。每一步都是更好地利用更长的上下文 。
context ──▶ [ Bigram 表 | MLP | Transformer ] ──▶ logits(V 维) ──▶ softmax ──▶ P(next)
↑ 只有这个盒子在进化,两端的接口从来没变
3. 训练:一条序列 = 很多个训练样本
序列 . e m m a . 自动产生 5 个样本:. → e、.e → m、.em → m、.emm → a、.emma → .。目标(target)就是输入右移一位。这叫 self-supervised(自监督) :不需要人工标注,互联网上任何文本都是训练数据——这正是 LLM 能用万亿 token 训练的前提。
在 Transformer 里,这 T 个位置的预测在一次 forward 中并行完成 (靠 causal mask:让每个位置只能看到自己和之前的 token、看不到答案,第 11 课),这叫 Teacher Forcing :训练时每个位置都用真实前文,而不是模型自己生成的前文。能并行的原因:真实前文全都在数据里,不用等模型先生成出来。
4. 生成:必须串行
推理时第 t 个 token 取决于第 t-1 个 token 的抽样结果 ,所以只能一个一个来。训练并行、推理串行——这个不对称性是第 36 课 Prefill/Decode 和第 37 课 KV Cache 的起点。
5. Base model 为什么只会续写
预训练目标就是「让续写像互联网文本」。你输入「法国首都是?」,在互联网上,这种问句后面常常跟着的是另一道题、一个论坛回复……所以 base model 的行为是文档续写器 ,不是问答助手。要让它回答问题,需要 post-training(第 31 课)。
代码片段
makemore 第一集的 bigram 核心(精简):
import torch
words = open('names.txt').read().splitlines() # 32,033 个名字
chars = sorted(set(''.join(words)))
stoi = {c: i+1 for i, c in enumerate(chars)}; stoi['.'] = 0
itos = {i: c for c, i in stoi.items()}
N = torch.zeros((27, 27))
for w in words:
cs = ['.'] + list(w) + ['.'] # 加开始/结束标记
for c1, c2 in zip(cs, cs[1:]):
N[stoi[c1], stoi[c2]] += 1 # 数相邻字符对
P = (N + 1) / (N + 1).sum(1, keepdim=True) # +1 平滑,每行归一化
# Autoregressive 生成:每次按当前字符那一行抽样
ix, out = 0, []
while True:
ix = torch.multinomial(P[ix], 1).item() # 按概率抽下一个 token
if ix == 0: break # 抽到 '.' 结束
out.append(itos[ix])
print(''.join(out))
P[ix] 就是「给定当前 token,下一个 token 的分布」——所有语言模型的输出接口都是这个形状(只是 V 从 27 变成了十几万)。
N + 1 是平滑(smoothing),避免某个组合概率为 0 导致 log(0) = -∞;神经网络版本里对应 regularization。
while True 串行循环就是 LLM 推理的原型,vLLM 的 decode 循环在结构上和它一样。
工程视角 / 真实世界
所有 GPT 类模型(Llama、Qwen、DeepSeek、GPT-4)都是 decoder-only autoregressive 模型(只有一叠带 causal mask 的 Transformer 层,从左到右生成),训练目标都是 next-token prediction。BERT 是另一类(masked LM:把句子中间挖空让模型填,能同时看左右两边),不能直接自回归生成。
预训练的 loss 就是所有位置 NLL 的平均。大模型训练报告中的 loss 曲线就是这个数;它能下降到多少与 tokenizer、数据分布有关,不同模型之间的 loss 不能直接比较。
「上下文窗口(context length)」= 模型一次最多能看的 x_<t 长度,如 GPT-2 为 1024,Llama 3.1 为 128K。它受位置编码(第 13、23 课)和 KV Cache 显存(第 37 课)限制。
💬 能聊的要点
「LLM 的训练目标就是 next-token prediction,通过概率链式法则,它等价于给整段文本建模;它是自监督的,所以能吃下互联网规模的数据。」
「训练时 teacher forcing,一条长度 T 的序列一次 forward 就得到 T 个预测,高度并行;推理时每个 token 依赖上一个的采样结果,必须串行——这是推理优化的根本矛盾。」
「makemore 里 bigram 计数表和一层神经网络收敛到同样的 loss,说明神经网络只是更可扩展的『统计方法』;从 bigram 到 Transformer,变的只是利用上下文的方式。」
常见误区
❌ LLM 一次生成一整句话 ✅ 每次 forward 只产出下一个 token 的分布,句子是循环抽样出来的。
❌ 训练时模型用自己生成的前文预测下一个 ✅ 训练用真实前文(teacher forcing),推理才用自己生成的。
❌ Base model 能力差,所以不会回答问题 ✅ Base model 知识已经很多,只是它的目标是续写文档;post-training 改的主要是「行为格式」。
自测(心算/口答即可)
一条 1024 token 的训练序列,提供了多少个 next-token 预测样本?答案 1024 个(每个位置预测它的下一个 token,target 是输入右移一位)。
27 个字符上完全均匀的猜测,loss 是多少?bigram 做到约 2.45 意味着什么?答案 ln 27 ≈ 3.30。2.45 低于它,说明模型学到了字符之间的统计规律;exp(2.45) ≈ 11.6,相当于每步在约 12 个候选里犹豫,而不是 27 个。
为什么计数表方法不能扩展到长上下文?答案 看前 k 个 token,表的行数是 Vᵏ,指数爆炸,而且绝大多数组合在数据中从未出现;神经网络通过 Embedding 共享参数、泛化到没见过的组合。
延伸
🎬 Karpathy:The spelled-out intro to language modeling: building makemore (bigram 计数 → 神经网络版 bigram,结论一致)
🎬 Karpathy:Building makemore Part 2: MLP (复现 Bengio 2003,引入 embedding 和 context 窗口)
🎬 李沐:《动手学深度学习》第 8 章「循环神经网络」中「语言模型和数据集」一节(n-gram 与条件概率)
📄 Bengio et al., 2003, A Neural Probabilistic Language Model (JMLR)
💻 github.com/karpathy/makemore
下一课 → 06 · Tokenization:BPE 是怎么切词的
06 · Tokenization:BPE 是怎么切词的
⏱ 10 分钟 | 前置:05 · Language Model 的本质 | 难度:★★☆
一句话
Tokenizer 把字符串变成整数序列;主流做法 **BPE(Byte Pair Encoding)**从 256 个字节出发,反复把「最常一起出现的相邻 token 对」合并成新 token,直到词表达到目标大小(如 GPT-2 的 50,257、Llama 3 的 128,256)。
为什么要关心
模型看不见字符,只看得见 token id。上下文长度、API 计费、推理速度、KV Cache 大小,全都按 token 算而不是按字符算。很多 LLM 的「怪毛病」——数不清 strawberry 里有几个 r、算术偶尔出错、对某些语言更贵——根源都在 tokenization。Karpathy 的说法是:很多奇怪问题,追到底都是 tokenization 的锅。
核心直觉
1. 三种切法的取舍
切分粒度
词表大小
序列长度
问题
Character / Byte
很小(256)
很长
序列长 → Attention 计算和 KV Cache 都贵;每个 token 信息量太低
Word
巨大(几十万+)
短
新词、拼写错误、多语言都会 OOV(Out-Of-Vocabulary,词表外)
Subword(BPE)
可控(3 万~20 万)
适中
折中:常见词是 1 个 token,罕见词拆成几块,永远不会 OOV
(Attention 是 Transformer 的核心计算,每个 token 都要和前面所有 token 交互,成本随序列长度快速增长,第 10 课;KV Cache 是推理时为每个历史 token 缓存的中间结果,第 37 课。两者都按 token 数计费,所以序列越短越省。)
makemore 用字符级(27 个 token)是为了教学简单;真实 LLM 都用 subword。
2. BPE 训练过程:像压缩算法一样「数对子、合并」
以字符串 aaabdaaabac 为例(BPE 本来就是一种压缩算法):
第 0 轮: a a a b d a a a b a c 最频繁的对: (a,a) → 合并为 Z
第 1 轮: Z a b d Z a b a c 最频繁的对: (a,b) → 合并为 Y
第 2 轮: Z Y d Z Y a c 最频繁的对: (Z,Y) → 合并为 X
第 3 轮: X d X a c 序列从 11 个 token 缩短到 5 个
在真实语料上跑几万轮,高频片段会依次长成 token:先是 t+h → th,然后 th+e → the,再到 the(带前导空格)……训练的产物只有两样:词表(vocab)和 合并规则的有序列表(merges) 。
🧩 难点拆解:BPE 的「训练」和「使用」是两件事
先直觉 :BPE 训练像是在语料上「发明缩写」,每轮把最常见的相邻对登记成一个新符号,并按发明顺序 记进一本规则簿;之后切新文本(encode)时不再统计频率,只是按规则簿的顺序 依次套用缩写。类比:先离线建好一张压缩字典,线上只查字典。
再代入例子 :用下面代码片段里的玩具语料 hello world, hello there 训练 5 轮,得到的规则簿(id 从 256 开始编,因为 0–255 已被单字节占用):
规则 1: (h, e) → 256 "he" 出现 3 次(hello×2、there×1)
规则 2: (256, l) → 257 "hel"
规则 3: (257, l) → 258 "hell"
规则 4: (258, o) → 259 "hello"
规则 5: (259, ' ') → 260 "hello " 序列从 24 个 token 缩到 13 个
现在 encode 一个训练时没见过 的词 help:
先拆成字节:h e l p(4 个 token)
套规则 1:he l p → [256, l, p]
套规则 2:hel p → [257, p]
规则 3–5 都匹配不上,结束 → 2 个 token:hel + p
一句话总结 :训练 = 按频率贪心地产出一张有序 合并表;encode = 按表的顺序反复合并直到无规则可用;没见过的词也总能切(最坏退回单字节)。
3. Byte-level BPE:永不 OOV
GPT-2 起,BPE 的起点不是「字符」而是 UTF-8 字节 (256 个)。UTF-8 是变长编码:英文字母 1 字节,常见汉字 3 字节(如「中」= E4 B8 AD),emoji 通常 4 字节。任何字符串——中文、emoji、乱码——都能表示成字节序列,所以最坏情况就是退化成逐字节,永远不会出现未知 token。
GPT-2 的词表:256 个字节 token + 50,000 次合并 + 1 个特殊 token <|endoftext|> = 50,257 。
4. 两个工程细节
Pre-tokenization(预切分) :先用正则把文本按「单词、数字、标点、空格」切开,BPE 只在块内合并。否则会合并出 dog.、dog!、dog? 这种浪费词表的 token。GPT-2/GPT-4 的 tokenizer 里都有一条这样的正则。
Special tokens :<|endoftext|>、<|im_start|>、<|eot_id|> 等,不由 BPE 合并产生,而是人为加入,用于标记文档边界和对话角色。Chat Template(第 31 课)就是靠它们把多轮对话拼成一条序列。
5. 词表大小的取舍
vocab ↑ ──▶ 同样文本 token 数 ↓ ──▶ Attention/KV Cache 更省,等效上下文更长
└──▶ Embedding 和 LM head 矩阵 ↑(V × d),softmax 更贵;罕见 token 训练不充分
V 是词表大小,d 是模型内部向量的维度(hidden size,如 4096)。Embedding 是输入端「token id → 向量」的查找表,LM head 是输出端「向量 → 每个 token 的打分」的矩阵,两者都有 V 行(第 07 课)。等效上下文更长 :上下文窗口按 token 数限制,每个 token 装的字符越多,同样窗口能放下的文字就越多。
近几年的趋势是词表变大,尤其为了多语言和代码:
模型
词表大小
GPT-2
50,257
Llama 2
32,000(SentencePiece BPE)
GPT-4(cl100k_base)
约 10 万
Llama 3
128,256(基于 tiktoken,含 256 个预留特殊 token)
GPT-4o(o200k_base)
约 20 万
6. Tokenization 带来的怪毛病
拼写/数字母 :strawberry 可能是 2–3 个 token,模型看不到单个字母,所以数 r 很难。
算术 :数字被切成不规则的块(如 1234 可能是 123+4),位对齐被打乱。
多语言成本 :同样意思的内容,词表里覆盖较少的语言会被切成更多 token——更贵、更慢、占更多上下文。
Glitch tokens :Karpathy 讲过的 SolidGoldMagikarp——tokenizer 训练语料里高频(某 Reddit 用户名),但 LLM 训练数据里几乎没出现,于是对应的 Embedding 几乎没被训练过,模型遇到它会行为异常。
代码片段
minbpe 风格的 BPE 训练核心:
def get_stats(ids):
counts = {}
for pair in zip(ids, ids[1:]): # 统计所有相邻 pair 出现次数
counts[pair] = counts.get(pair, 0) + 1
return counts
def merge(ids, pair, new_id):
out, i = [], 0
while i < len(ids):
if i < len(ids) - 1 and (ids[i], ids[i+1]) == pair:
out.append(new_id); i += 2 # 把这一对替换成新 token
else:
out.append(ids[i]); i += 1
return out
ids = list("hello world, hello there".encode("utf-8")) # 从 bytes 开始(0..255)
merges = {}
for k in range(5): # 玩具文本只够 5 次有意义的合并;真实场景做几万次
stats = get_stats(ids)
pair = max(stats, key=stats.get) # 最高频 pair
new_id = 256 + k
ids = merge(ids, pair, new_id)
merges[pair] = new_id # 记录合并规则(有序)
# 结果:he → hel → hell → hello → "hello ",len(ids) 从 24 降到 13
训练出的 merges 就是 tokenizer 的「模型」;encode 时对新文本按同样顺序应用这些合并,decode 时把 id 展开回字节再按 UTF-8 解码。
Tokenizer 是独立于 LLM 单独训练 的,用的是自己的语料和算法(不是梯度下降)。它一旦定下来,LLM 的 Embedding 层大小也就定了,之后几乎不可能更换。
真实实现(tiktoken、HuggingFace tokenizers)用 Rust 写,并在合并前做正则预切分。
工程视角 / 真实世界
常用库:OpenAI tiktoken (只做 encode/decode,快)、Google SentencePiece (Llama 1/2 使用,可训练,直接处理原始文本)、HuggingFace tokenizers (Rust 实现,模型仓库里的 tokenizer.json)。
推理引擎里 tokenizer 常是一个被低估的 CPU 开销:高并发下 detokenize(逐 token 流式输出)需要处理「一个汉字被切在两个 token 之间」的情况,vLLM 和 SGLang 都有增量 detokenize 逻辑,并把 tokenizer 放在独立进程里避免阻塞 GPU 调度(第 45、46 课)。
粗略经验(OpenAI 的说法):英文 1 token ≈ 4 个字符 ≈ 0.75 个单词。中文的比例强烈依赖词表,务必用目标模型的 tokenizer 实测。
💬 能聊的要点
「BPE 是子词切分的折中:词表可控、永不 OOV、常见词一个 token;byte-level BPE 从 256 个字节起步,贪心地合并最高频的相邻对。」
「词表大小是个取舍:变大会让序列变短、KV Cache 和 Attention 更省,但 embedding/LM head 变大、softmax 更贵。Llama 从 2 代的 32K 扩到 3 代的 128K 就是为了多语言和压缩率。」
「很多 LLM 的怪问题——数字母、算术、glitch token、多语言更贵——根源都在 tokenization,因为模型根本看不到字符。」
常见误区
❌ 一个 token 就是一个单词 ✅ 是一个子词片段,常带前导空格( the 和 the 是不同 token);罕见词会被拆成多块。
❌ Tokenizer 是和模型一起端到端训练的 ✅ 它是预先用 BPE 算法单独训练好的,与神经网络的梯度下降无关。
❌ 换一个更好的 tokenizer 很容易 ✅ Embedding 和 LM head 的每一行都绑定到具体 token id,换 tokenizer 基本等于重新训练。
自测(心算/口答即可)
GPT-2 的 50,257 是怎么来的?答案 256 个基础字节 token + 50,000 次 BPE 合并 + 1 个 <|endoftext|> 特殊 token。
同一段文本,用 32K 词表和 128K 词表 tokenizer,哪个 token 数更少?这对推理有什么影响?答案 通常 128K 词表更少(更多长片段被合并成单个 token)。token 越少,prefill 计算、KV Cache 显存、decode 步数都越少,等效上下文更长。
为什么 LLM 数不准 strawberry 里有几个 r?答案 模型输入的是 token id 而非字母;这个词被切成少数几个子词 token,字母层面的信息没有直接暴露给模型,只能靠记忆间接推断。
延伸
🎬 Karpathy:Let's build the GPT Tokenizer (从零实现 BPE、讲 GPT-2/GPT-4 的正则预切分、SentencePiece、SolidGoldMagikarp 等怪现象)
🎬 李沐:《动手学深度学习》第 14 章中「子词嵌入」一节(含字节对编码 BPE)
📄 Sennrich, Haddow & Birch, 2016, Neural Machine Translation of Rare Words with Subword Units (ACL,把 BPE 引入 NLP)
💻 github.com/karpathy/minbpe;github.com/openai/tiktoken
下一课 → 07 · Embedding:把 token 变成向量
07 · Embedding:把 token 变成向量
⏱ 10 分钟 | 前置:06 · Tokenization | 难度:★☆☆
一句话
Embedding 就是一张形状为 V × d 的可学习查找表:token id 是行号,查出来的那一行(一个 d 维向量)就是这个 token 进入神经网络的样子;它在数学上等价于「one-hot 向量 × 矩阵」,但实现上只是一次按行索引 。
为什么要关心
Tokenizer 给出的是整数,而整数之间的大小没有意义(id 1000 并不比 id 999「大一点」)。神经网络需要连续的向量才能做矩阵乘法和梯度下降。Embedding 是 LLM 的第一层,也是最后一层(LM head)的「镜像」;它常占小模型参数的相当比例,并直接关系到 weight tying 、词表扩充、以及 RAG 里说的「embedding 模型」——后者是另一个东西,别混淆。
核心直觉
1. 查表:one-hot × 矩阵 = 取一行
两个符号:V = 词表大小(vocab size,GPT-2 是 50,257);d = 每个 token 向量的长度,也叫 hidden size 或 d_model(GPT-2 small 是 768,Llama 3 8B 是 4096)。所以 Embedding 就是一张 V 行 × d 列的表,类比数据库里 SELECT vector FROM embedding WHERE token_id = ?,只不过表里的值是训练出来的。one-hot 向量是长度 V、只有第 id 位为 1、其余全 0 的向量。
token id = 2
one-hot: [0, 0, 1, 0, ..., 0] (长度 V)
×
Embedding 矩阵 E (V × d):
row 0: [ 0.12, -0.40, ..., 0.07]
row 1: [-0.33, 0.95, ..., -0.21]
row 2: [ 0.58, 0.10, ..., 0.44] ◀── 结果就是这一行
...
one-hot 乘矩阵,结果恰好就是矩阵的第 2 行。用小数字看(V=3, d=2):[0, 0, 1] × [[1, 2], [3, 4], [5, 6]] = 0·[1,2] + 0·[3,4] + 1·[5,6] = [5, 6]——0 把其他行全部清掉了。所以没必要真的构造 V 维 one-hot 再做 matmul——直接 E[2] 取行即可,这就是 nn.Embedding。这个等价关系也说明:Embedding 就是一个没有 bias 的 Linear 层 ,梯度下降照样能训练它,只是每一步只有被用到的那些行会收到梯度。
2. 为什么是「学出来」的
Embedding 初始是随机数。训练时,next-token loss 的梯度会一路反传到 E 的对应行,把它往「有助于预测」的方向推。推理 1→2→3:
如果两个 token 后面常跟相似的东西(如 a 和 e 后面都常跟辅音),它们要产生相似的预测;
后面的网络对相近的输入向量给出相近的输出;
所以梯度会把这两个 token 的向量推向同一片区域。
结果是:在相似上下文中出现、起相似作用的 token,向量会靠得很近 。
makemore Part 2 有一个很直观的实验:Karpathy 把 27 个字符的 embedding 设成只有 2 维,训练后画在平面上,元音 a、e、i、o、u 聚在一起 ,.(开始/结束)和某些罕见字母各自远离。没有任何人告诉模型什么是元音——它从「这些字母在名字里出现的位置相似」中自己学出来了。
dim2
│ q
│ .
│ a e
│ i o u ← 元音挤在一起
│ b d k
│ m n r l
└─────────────── dim1
(示意图,非真实坐标)
3. 经典例子:word2vec 的向量算术
Mikolov 等人 2013 年的 word2vec 展示了 vec(king) - vec(man) + vec(woman) ≈ vec(queen):语义关系对应到空间里的方向。现代 LLM 的 token embedding 同样有几何结构,但要记住:LLM 的输入 embedding 只是起点 ,真正「理解上下文」发生在后面的 Transformer 层里。
4. Static vs Contextual
Token embedding(静态) :bank 这个 token 永远查出同一个向量,不管是「river bank」还是「bank account」。
Hidden state(上下文相关,指模型中间层对每个 token 的 d 维表示) :经过若干层 Attention(让每个 token 读取其他 token 信息的机制,第 10 课)之后,同一个 bank 在不同句子里的表示变得不同。这是 Transformer 的核心价值(Part 2)。
5. 输出端:LM head 是 Embedding 的镜像
最后一层叫 LM head (Language Model head),要把 d 维隐藏状态变回 V 维 logits(每个 token 一个分数):logits = h @ Wᵀ,W 也是 V × d。直觉:logits 的第 i 个分数 = h 与 W 第 i 行的点积,即「h 和 token i 的向量有多像」。「输入:id → 向量」和「输出:向量 → 各 id 的分数」形状对称,于是有了 Weight Tying(权重共享) :两者用同一个矩阵。
GPT-2 small
Llama 3 8B
V × d
50,257 × 768
128,256 × 4,096
Embedding 参数
约 38.6M
约 525M
Weight tying
是(输入输出共享)
否(输入、输出各一份,合计约 1.05B)
占总参数
约 31%(总 124M)
约 13%(输入+输出两份合计;总约 8B)
小模型通常 tie(省下的比例很可观,如 Llama 3.2 1B/3B 就开启了 tie_word_embeddings),大模型通常不 tie(占比小了,分开有更多自由度)。
6. 位置信息怎么办
Embedding 查表对位置无感:dog bites man 和 man bites dog 查出的是同一堆向量。GPT-2 的做法是再加一张位置 embedding 表 (1024 × 768,每个位置一个可学习向量),与 token embedding 相加。现代模型改用 RoPE(Rotary Position Embedding:按位置把 Q/K 向量旋转一个角度),在 Attention 里注入位置(第 13 课)。
代码片段
import torch
import torch.nn as nn
V, d = 27, 10 # makemore 规模:27 个字符,10 维
emb = nn.Embedding(V, d) # 就是一个 V×d 的可学习矩阵
ids = torch.tensor([[5, 13, 13, 1]]) # (batch=1, T=4) 的 token id
x = emb(ids) # (1, 4, 10):每个 id 取一行
# 等价写法:one-hot × 矩阵(只是为了说明,实践中不这么写)
one_hot = nn.functional.one_hot(ids, V).float() # (1, 4, 27)
x2 = one_hot @ emb.weight # (1, 4, 10)
assert torch.allclose(x, x2)
# GPT-2 风格:token embedding + position embedding
pos_emb = nn.Embedding(1024, d)
h = emb(ids) + pos_emb(torch.arange(4)) # 广播相加,进入 Transformer
emb.weight 的形状就是 (V, d);HuggingFace 模型里通常叫 model.embed_tokens.weight,输出端叫 lm_head.weight。
assert 验证了「查表 = one-hot 乘矩阵」,这就是为什么 embedding 可以用梯度训练。
后续所有 Transformer 层处理的都是 h 这样的 (B, T, d) 张量;d 在 Llama 3 8B 里是 4096,叫 hidden size 或 d_model。
工程视角 / 真实世界
扩词表 :给模型加新 token(如新语言、新特殊 token)时,需要在 Embedding 和 LM head 各加几行(model.resize_token_embeddings),新行要初始化并经过训练才有意义。
词表 padding :实际矩阵行数常比词表略大、并对齐到 64/128 的倍数(如 Qwen2.5 的 embedding 行数就比 tokenizer 词表多),为了 GPU 矩阵运算效率和张量并行切分。Karpathy 在 GPT-2 复现里把 50,257 改为 50,304(128 的倍数)也带来了提速。
张量并行(Tensor Parallelism,把一个矩阵切开放到多张 GPU 上,第 30 课)下的 Embedding :词表很大时按行(vocab 维度)切到多张卡,叫 Vocab Parallel Embedding(Megatron、vLLM 里都有同名实现)。
别混淆 :RAG 里的「embedding 模型」(如 BGE、OpenAI text-embedding 系列)是把整段文本 编码成一个向量用于检索的独立模型 ;本课讲的是 LLM 内部每个 token 的输入向量。
💬 能聊的要点
「Embedding 本质是一个 V×d 的查找表,等价于 one-hot 乘矩阵,所以可以端到端用梯度训练;语义相近的 token 会在训练中自然聚到一起。」
「LM head 是 embedding 的镜像,小模型常做 weight tying 省参数——GPT-2 small 的 embedding 就占了约 31% 参数;Llama 3 8B 不 tie,embedding+head 约 1B。」
「token embedding 是静态的,上下文相关的表示来自之后的 Attention 层;RAG 的文本 embedding 模型则是另一回事。」
常见误区
❌ Embedding 是用 word2vec 预先算好再固定的 ✅ 现代 LLM 的 embedding 随机初始化、与整个网络一起训练。
❌ LLM 的 embedding 层就是 RAG 用的 embedding ✅ 前者是逐 token 的输入查表,后者是专门训练的句子/段落级编码模型。
❌ Embedding 层计算量大 ✅ 它只是索引取行,几乎不算 FLOPs;贵的是输出端的 LM head(一次 d × V 的 matmul)。
自测(心算/口答即可)
词表 128,256、d = 4096,embedding 在 BF16 下占多少显存?答案 128,256 × 4096 ≈ 5.25 亿参数 × 2 字节 ≈ 1.05GB;如果 LM head 不共享,再加约 1.05GB。
一个 batch 中只出现了 500 个不同的 token,这一步 embedding 矩阵有几行会收到非零梯度?答案 只有这 500 行(按行索引,未被查到的行梯度为 0)。但「梯度为 0」不等于「参数不动」:用 AdamW 时,weight decay 会让所有行都缩小一点,而以前被用过的行还带着历史动量 m(第 09 课),也会继续被更新。
Weight tying 为什么对小模型更有价值?答案 小模型 d 小、层数少,V × d 占总参数比例高(GPT-2 small 约 31%),共享能显著减少参数;大模型中这个比例小,收益有限。
延伸
🎬 Karpathy:Building makemore Part 2: MLP (embedding 查找表 C、「one-hot 乘矩阵等价于索引」的讲解,以及 2 维 embedding 可视化)
🎬 Karpathy:Let's reproduce GPT-2 (124M) (weight tying 以及把词表 pad 到 50,304 的提速技巧)
🎬 李沐:《动手学深度学习》第 14 章「自然语言处理:预训练」中「词嵌入(word2vec)」一节
📄 Mikolov et al., 2013, Efficient Estimation of Word Representations in Vector Space (word2vec)
📄 Press & Wolf, 2017, Using the Output Embedding to Improve Language Models (weight tying)
下一课 → 08 · Softmax、Cross-Entropy 与 Temperature
08 · Softmax、Cross-Entropy 与 Temperature
⏱ 10 分钟 | 前置:07 · Embedding | 难度:★★☆
一句话
模型最后输出 V 个任意实数 logits ;Softmax 把它们变成概率分布;训练时用 Cross-Entropy = -log p(正确 token) 打分;推理时把 logits 先除以 Temperature 再 softmax,用来调节输出的「保守 vs 发散」。
为什么要关心
这是模型输出端的全部机制:训练的 loss、推理的采样、API 里的 temperature 参数、「loss 从 10.8 开始降」「perplexity」这些说法都在这里。它也是个实打实的 Infra 问题:128K 词表 × 长序列的 logits 张量非常大,大模型训练里有专门的 fused cross-entropy kernel 来省显存。
核心直觉
1. Logits → Softmax:把分数变成概率
Logits 是模型最后一层(LM head,第 07 课)输出的、还没归一化的原始分数,词表里每个 token 一个(V 个),可正可负、没有约束。Softmax:
p_i = exp(z_i) / Σ_j exp(z_j)
符号:z_i 是第 i 个 token 的 logit;exp(z) = eᶻ(e ≈ 2.718);Σ_j 表示对所有 j 求和。大白话:先取指数把所有数变成正数(并放大差距),再除以总和让它们加起来等于 1。
例:3 个候选 token 的 logits = [2, 1, 0]
logits exp prob
2 → 7.39 → 0.665 ████████████████ 总和 = 7.39 + 2.72 + 1.00 = 11.11
1 → 2.72 → 0.245 ██████ 0.665 = 7.39 / 11.11
0 → 1.00 → 0.090 ██
注意是 soft -max:最大的拿大头,但其他选项也保留概率(hard max 只会给最大值 1、其余 0)。
数值稳定性 :exp(1000) 会溢出。标准技巧是先减去最大值:softmax(z) = softmax(z - max(z)),结果不变但不会溢出。PyTorch 内部都做了这一步。
2. Cross-Entropy:只看「正确答案拿到了多少概率」
loss = -log p_target
大白话:你给正确答案的概率越高,loss 越低;给得越低,罚得越狠。
p_target
loss = -ln p
1.0
0(完美)
0.5
0.69
0.1
2.30
0.01
4.61
→ 0
→ ∞
(本课 log 均指自然对数 ln。)接上面的例子:若正确答案是第 0 个 token,loss = -ln 0.665 ≈ 0.41;若正确答案是第 2 个,loss = -ln 0.090 ≈ 2.41。
-log 的形状保证了「很自信地答错」会被重罚。它和第 05 课的 NLL 是同一个东西,也是 **Maximum Likelihood Estimation(最大似然估计:选让已观测数据出现概率最大的参数)**的直接体现:最小化平均 cross-entropy = 最大化训练数据出现的概率。
🧩 难点拆解:为什么是 -log,而不是直接用 1 - p
先直觉 :-log 同时解决两个问题——
连乘变连加 :一段文本的概率是每个 token 概率的连乘(第 05 课),几千个小于 1 的数相乘会下溢成 0;取 log 后变成加法,数值稳定,还能按 token 取平均。
惩罚不对称 :1 - p 从 p=0.01 到 p=0.001 几乎没区别(0.99 vs 0.999);-ln p 则从 4.6 涨到 6.9,「错得离谱」会被持续重罚。
再代入数字 :一段 3 个 token 的文本,模型给正确 token 的概率分别是 0.5, 0.1, 0.9:
连乘:0.5 × 0.1 × 0.9 = 0.045 → -ln 0.045 ≈ 3.10
连加:0.69 + 2.30 + 0.105 ≈ 3.10 (完全相等,log 把乘变成了加)
平均 loss = 3.10 / 3 ≈ 1.03 ← 训练日志里看到的就是这个「每 token 平均」
梯度长什么样 (为什么它好训):cross-entropy 对 logits 的梯度恰好是 p - onehot(target)。上例 p = [0.665, 0.245, 0.090]、target = 0 时,梯度 = [-0.335, 0.245, 0.090]:正确 token 的 logit 被往上推,其他 token 按「它们抢走了多少概率」往下压。概率分配越错,梯度越大;完全答对时梯度为 0。
一句话总结 :-log 把「整段文本的概率」拆成「每个 token 的罚分之和」,并且对自信的错误罚得最狠;它对 logits 的梯度就是「预测分布 - 正确答案」。
3. 两个必背的数
初始化时的 loss ≈ ln V 。刚初始化的模型应该对所有 token「一视同仁」,每个 token 概率约 1/V,loss ≈ -ln(1/V) = ln V:
makemore(V=27):ln 27 ≈ 3.30
GPT-2(V=50,257):ln 50257 ≈ 10.82
Llama 3(V=128,256):ln 128256 ≈ 11.76
这是一个非常实用的 sanity check(上线前的冒烟测试:数字不对就说明哪里有 bug)。Karpathy 在 makemore Part 3 专门讲过:如果初始 loss 远高于 ln V,说明初始化让 logits 过大、模型「自信地错」,会浪费前一段训练去纠正;修正方法是把输出层权重初始化得很小。
Perplexity(困惑度)= exp(loss) :可以理解为「模型平均在多少个选项之间犹豫」。loss 2.45 → perplexity ≈ 11.6;loss 10.82(随机)→ perplexity ≈ 50,257,恰好是词表大小。
4. Temperature:推理时的「旋钮」
采样前把 logits 除以温度 T:p = softmax(z / T)。同样的 logits [2, 1, 0]:
T = 0.5 : [0.867, 0.117, 0.016] 更尖锐 → 保守、确定、可能重复
T = 1.0 : [0.665, 0.245, 0.090] 模型原始分布
T = 2.0 : [0.506, 0.307, 0.186] 更平坦 → 多样、有创意、易胡说
T → 0 : [1, 0, 0] 退化为 greedy(永远选最大)
大白话:T < 1 放大 logits 之间的差距,T > 1 缩小差距。Temperature 不改变排序 ,只改变「有多偏向第一名」。它只在推理(采样)时使用,训练时 T 恒为 1。Greedy (贪心解码)= 每步直接选概率最大的 token,不做随机抽样。top-k / top-p 等其他采样策略见第 17 课。
🧩 难点拆解:Temperature 为什么能「调尖锐度」
先直觉 :softmax 只关心 logits 之间的差 。两个 token 的概率之比是 exp((z₁ - z₂) / T)——除以 T 就是把所有差距按比例缩放。T 小 → 差距被放大 → 第一名独占;T 大 → 差距被压缩 → 大家接近平分。
再代入数字 (logits [2, 1, 0],第一名比第二名高 1):
T
除以 T 后
第一名 / 第二名 概率比 = exp(1/T)
0.5
[4, 2, 0] → exp [54.6, 7.39, 1],总和 63.0 → [0.867, 0.117, 0.016]
e² ≈ 7.4 倍
1.0
[2, 1, 0]
e¹ ≈ 2.7 倍
2.0
[1, 0.5, 0]
e^0.5 ≈ 1.65 倍
→ 0
差距 → ∞
→ ∞,退化为 greedy
一句话总结 :Temperature 是 logits 差距的「缩放系数」,排序不变,只调节第一名的领先幅度。
代码片段
import torch
import torch.nn.functional as F
logits = torch.tensor([2.0, 1.0, 0.0])
# 1) 数值稳定的 softmax
def softmax(z):
z = z - z.max() # 减 max 防溢出,结果不变
e = z.exp()
return e / e.sum()
print(softmax(logits)) # tensor([0.665, 0.245, 0.090])
# 2) Cross-entropy:只取 target 位置的 -log p
target = 0
loss = -torch.log(softmax(logits)[target]) # 0.408
loss2 = F.cross_entropy(logits[None], torch.tensor([target]))
# PyTorch 直接吃 logits,内部用 log-softmax,更稳更快
# 3) Temperature 采样
def sample(logits, T=1.0):
probs = F.softmax(logits / T, dim=-1) # T 越小越尖锐
return torch.multinomial(probs, 1).item()
F.cross_entropy 接收的是 logits 而不是概率,内部把 softmax 和 log 合并成 log_softmax,避免先算出极小概率再取 log 带来的精度损失。常见 bug 就是先手动 softmax 再传进去。
训练时的 logits 形状是 (B, T, V)(B = batch 里的序列条数,这里的 T 是序列长度,不是 temperature ,V = 词表大小),要 reshape 成 (B*T, V) 与 (B*T,) 的 target 对齐。
sample 里的 logits / T 就是 OpenAI API temperature 参数的全部含义(T=0 时各实现通常特判为 argmax)。
工程视角 / 真实世界
logits 显存 :Llama 3 的 V = 128,256,一个 batch 若有 8 × 8192 = 65,536 个 token,logits 就是 65,536 × 128,256 ≈ 84 亿个数,FP32 约 34GB,还没算 softmax 的中间结果。所以训练框架会用 fused linear + cross-entropy (例如 Liger Kernel 的实现)按 chunk 计算,不物化整个 logits 张量。
精度 :softmax / cross-entropy 通常在 FP32 下计算,即使模型其余部分用 BF16(第 28 课)。
推理只需要最后一个位置 :生成时只对序列最后一个 token 计算 LM head 和 softmax,vLLM 的 sampler 就是在这里应用 temperature、top-p、重复惩罚等参数。
「推理模型建议 temperature 0.6」这类官方建议(如 DeepSeek-R1 的推荐设置)本质是在确定性和多样性之间取折中。
💬 能聊的要点
「语言模型的 loss 就是正确 token 的负对数概率,最小化它等价于最大似然;随机初始化时 loss 应接近 ln V,GPT-2 约 10.8,这是最基本的 sanity check。」
「Temperature 只是在 softmax 前把 logits 除以 T,不改变候选排序,只改变分布的尖锐程度;T→0 等价于 greedy。」
「大词表下 logits 张量是训练显存的隐藏大户,(B·T)×V 在 FP32 下可以到几十 GB,所以有 fused cross-entropy 这类分块计算的 kernel。」
常见误区
❌ Temperature 调高会让模型「更聪明、更有创意」 ✅ 它只是把分布拍平,低概率(可能是错误的)token 被采到的机会变大,更多样也更容易胡说。
❌ 把 softmax 后的概率传给 F.cross_entropy ✅ PyTorch 的 cross_entropy 要的是 raw logits,它内部自带 log_softmax。
❌ 不同模型的 loss 可以直接比较 ✅ loss 取决于 tokenizer(token 粒度不同)和评估数据,只有同一 tokenizer、同一数据上才可比。
自测(心算/口答即可)
模型给正确 token 的概率是 0.5,这个位置的 loss 是多少?答案 -ln 0.5 ≈ 0.69。
你训练一个 V = 32,000 的模型,第 0 步 loss 是 25,正常吗?答案 不正常。期望约 ln 32000 ≈ 10.4。25 说明初始化让 logits 过大、模型对错误答案过度自信,应缩小输出层初始化。
logits 为 [3, 3, 0],temperature 从 1 调到 0.1,前两个 token 的相对概率会变吗?答案 不会,两者 logits 相同,永远各占一半;第三个 token 的概率会趋近 0。Temperature 只改变不同 logits 之间的相对差距。
延伸
🎬 Karpathy:The spelled-out intro to language modeling: building makemore (从计数推出 NLL 损失、引入 softmax)
🎬 Karpathy:Building makemore Part 3: Activations & Gradients, BatchNorm (开头讲「初始 loss 应该约为 -ln(1/27) ≈ 3.30」以及如何修正过大的初始 logits)
🎬 李沐:《动手学深度学习》第 3 章「softmax 回归」一节(softmax 与交叉熵损失)
📄 Hinton, Vinyals & Dean, 2015, Distilling the Knowledge in a Neural Network (temperature 在 softmax 中的经典用法)
下一课 → 09 · Training Loop 全貌:Batch、AdamW、LR Schedule、Overfitting
09 · Training Loop 全貌:Batch、AdamW、LR Schedule、Overfitting
⏱ 10 分钟 | 前置:08 · Softmax、Cross-Entropy 与 Temperature | 难度:★★☆
一句话
所有训练都是同一个五步循环:取一个 batch → forward 算 loss → backward 算梯度 → optimizer 更新参数 → 调整 learning rate ;LLM 的标准配方是 AdamW + warmup/cosine LR + gradient clipping ,而 AdamW 每个参数额外的 2 个状态正是训练显存爆炸的主因。
为什么要关心
Part 1 的所有零件(loss、梯度、backprop、embedding、cross-entropy)在这里拼成一台机器。这台机器的每个旋钮都直接对应 Infra 问题:batch size 决定需要多少卡和梯度累积步数;AdamW 决定每参数 16 字节的显存账本(ZeRO/FSDP 的出发点);lr 调度决定 loss 曲线的形状和训练能否稳定。
核心直觉
1. 循环的骨架
┌──────────────────────────────────────────────┐
▼ │
DataLoader: (B, T) token ids ──▶ model(x) ──▶ loss │
│ │
loss.backward()│ │
▼ │
clip_grad_norm_(1.0) │ │
▼ │
optimizer.step() + zero_grad() │ │
▼ │
scheduler: 更新 lr ────┴──────┘
2. Batch:一次喂多少
LLM 里 batch 的形状是 (B, T):B 条序列,每条 T 个 token。更常用的单位是 每步 token 数 = B × T。例如 Karpathy 复现 GPT-2 124M 时用每步 2^19 = 524,288 个 token(对齐 GPT-3 论文里同尺寸模型约 0.5M token 的 batch)。
一张卡放不下这么大怎么办?Gradient Accumulation(梯度累积) :每次只 forward/backward 一个 micro-batch (一张卡一次真正塞进显存的那一小批),梯度累加(第 03 课的 +=),攒够 N 次再 step() 一次,数学上等价于大 batch。多卡时再乘上 Data Parallel 的卡数(第 29 课):
global batch = micro_batch × grad_accum_steps × num_GPUs
代入数字:假设 micro-batch 为 16 条 × 1024 token = 16,384 token;要凑 524,288 token,单卡需累积 524,288 / 16,384 = 32 步;8 张卡数据并行则每卡只需 4 步。
3. 优化器的进化:SGD → Momentum → Adam → AdamW
优化器
核心思想
每参数额外状态
SGD
θ -= lr · g
0
SGD + Momentum
梯度的指数移动平均当方向,像带惯性的球,减少震荡
1
Adam
Momentum(一阶矩 m)+ 每个参数按历史梯度平方(二阶矩 v)自适应缩放步长
2
AdamW
Adam + decoupled weight decay :权重衰减直接作用于参数,不混进梯度里
2
Adam 的直觉:梯度一直很大的参数,步子迈小一点;梯度一直很小的参数,步子迈大一点 ,于是不同参数不需要各自调 lr。AdamW 修正了 Adam 中 L2 正则与自适应缩放「互相干扰」的问题,成为 Transformer 训练的默认选择。
🧩 难点拆解:AdamW 的两个「矩」m 和 v
先直觉 :Adam 给每个参数配了两个计数器,都是 EMA (Exponential Moving Average,指数移动平均——和监控系统里平滑 QPS 曲线的 EWMA 是同一个东西:新值 = β × 旧值 + (1-β) × 本次观测):
m = β1·m + (1-β1)·g:梯度的平均值(一阶矩)——「最近一段时间主要往哪个方向推」,β1 = 0.9 约等于看最近 10 步
v = β2·v + (1-β2)·g²:梯度平方的平均值(二阶矩)——「最近梯度的典型幅度」,β2 = 0.95 约看最近 20 步
更新:θ -= lr × m / (√v + eps)。m / √v 是「方向 ÷ 幅度」,大约落在 -1 到 1 之间,所以每个参数每步大约挪 lr 这么远 ,与梯度的绝对大小无关
(训练最开始 m、v 从 0 起步会偏小,Adam 会除以 1 - βᵗ 做 bias correction 修正,不影响理解。)
再代入数字 (lr = 3e-4,看稳定后的状态):
参数
最近的梯度 g
m
√v
每步挪动 ≈ lr × m/√v
A
一直是 10
10
10
1.0 × lr
B
一直是 0.01
0.01
0.01
1.0 × lr
C
+1、-1 交替(噪声)
≈ ±0.05
1
≈ 0.05 × lr
A 和 B 梯度差 1000 倍,但步长一样——这就是「自适应」;C 的梯度方向来回翻,m 互相抵消,几乎不动——这就是 momentum 的「抗噪」。
Decoupled weight decay 是额外一行:θ -= lr × λ × θ(λ = weight_decay)。θ = 1.0、lr = 3e-4、λ = 0.1 时,每步把它缩小 3e-5,持续把不常用的大参数往 0 拉。AdamW 把这一行放在 m/√v 之外 ;老式 Adam + L2 则把它加进梯度 g 里,结果会被 √v 一起缩放,梯度大的参数反而几乎没被衰减。
一句话总结 :m 管方向(平滑噪声),v 管尺度(把每个参数的步长归一化到约 lr),weight decay 单独一行往 0 拉;代价是每个参数多存 m、v 两个 FP32 数。
LLM 常用超参数(Llama 2 论文公开的配置):β1 = 0.9, β2 = 0.95, eps = 1e-5, weight_decay = 0.1,梯度裁剪 1.0,7B 模型峰值 lr 3e-4。Gradient clipping(梯度裁剪) :如果所有参数梯度拼起来的长度(global norm)超过 1.0,就整体等比例缩小到 1.0,防止一步迈太大。
4. 显存账本:为什么是「16 字节 / 参数」
Mixed precision(混合精度,第 28 课) :forward/backward 用 2 字节的 BF16 算(快、省显存),但另存一份 4 字节 FP32 的「master 权重」专门用来做更新——因为 3e-5 这种小增量加到 BF16 上会被舍入掉。混合精度训练 + Adam 下(ZeRO 论文的经典算法):
BF16 权重 2 bytes
BF16 梯度 2 bytes
FP32 master 权重 4 bytes
FP32 Adam m 4 bytes
FP32 Adam v 4 bytes
─────────────────────────
合计 16 bytes / 参数 (还没算 activations)
7B 模型:7e9 × 16 bytes ≈ 112GB,单张 80GB 的 GPU 连参数状态都放不下。ZeRO 论文里的例子是 7.5B 参数模型需要 120GB。这就是为什么要把这些状态切分到多张卡 (ZeRO / FSDP,第 29 课),而推理只需要 2 bytes/参数的权重。
5. LR Schedule:先热身,再慢慢降
lr
│ ╭──────╮
│ ╱ ╲___
│ ╱ ╲___
│ ╱ ╲____
│ ╱ ╲______ min_lr(如峰值的 10%)
└──┴───────────────────────────────── step
warmup cosine decay
Warmup :训练初期参数是随机的、Adam 的 v 统计还不准(只见过几步梯度),直接用大 lr 容易炸。先从接近 0 线性升到峰值(Llama 2 用了 2000 步)。类比:服务发布时的灰度放量,先小流量再全量。
Cosine Decay :之后按余弦曲线(半个 cos 周期,先缓后急再缓)降到峰值的约 10%。例:峰值 3e-4,结束时约 3e-5;走到一半时约为 (3e-4 + 3e-5) / 2 ≈ 1.65e-4。后期小步长用来「精细下降」到更低的 loss——就像蒙眼下山快到谷底时要迈小步,否则会在谷底两侧来回跳。
WSD(Warmup-Stable-Decay) :MiniCPM 等工作推广的替代方案——峰值 lr 保持很久,最后一小段快速衰减。好处是不用预先定好总步数,可以随时从 stable 阶段的 checkpoint 分叉出去做 decay。
6. Overfitting 与正则化
判断:盯 train loss vs val loss 。val 开始上升而 train 继续下降 → 过拟合。
LLM 预训练很少过拟合 :数据通常只过约 1 个 epoch (把整个训练集完整过一遍叫 1 个 epoch),每个 batch 都是「新数据」,train/val loss 几乎重合。真正要担心的是欠拟合(模型或算力不够)。
微调(SFT)容易过拟合 :数据量小、跑多个 epoch,要早停、调小 lr、控制 epoch 数。
常见正则化:Weight Decay (把参数往 0 拉)、Dropout (训练时随机置零部分激活;GPT-2 用 0.1,现代大模型预训练多设为 0)、更多数据。
代码片段
model = GPT(config)
opt = torch.optim.AdamW(model.parameters(), lr=6e-4,
betas=(0.9, 0.95), weight_decay=0.1)
accum = 8 # gradient accumulation 步数
for step in range(max_steps):
lr = get_lr(step) # warmup + cosine decay
for g in opt.param_groups: g['lr'] = lr
for micro in range(accum):
x, y = train_loader.next_batch() # (B, T),y = x 右移一位
with torch.autocast('cuda', dtype=torch.bfloat16): # 混合精度
logits = model(x) # (B, T, V)
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), y.view(-1))
(loss / accum).backward() # 梯度 +=,除以 accum 取平均
norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 防 loss spike
opt.step() # AdamW 更新
opt.zero_grad(set_to_none=True) # 清空梯度,准备下一步
if step % eval_interval == 0:
val_loss = evaluate(model, val_loader) # 盯 train vs val
loss / accum 很关键:cross_entropy 默认取 batch 内平均,累积 8 次若不除,梯度就被放大 8 倍。Karpathy 在 GPT-2 复现视频里专门指出过这个坑。
clip_grad_norm_ 返回裁剪前的全局梯度范数,训练日志里的 grad norm 就是它;它突然变大通常是不稳定的前兆。
torch.autocast(..., bfloat16) 让 forward 里的 matmul 自动用 BF16 跑,而参数本身仍是 FP32(即上面账本里的 master 权重)。注意:16 B/参数是「BF16 权重 + FP32 master」的标准账本,具体实现不同会在 12–18 B 之间浮动(例如这种 autocast 写法梯度也是 FP32,就是 4+4+4+4,见第 28 课)。
这段代码结构和 nanoGPT / build-nanogpt 的 train_gpt2.py 基本一致;分布式训练只是在外面包一层 DDP(Distributed Data Parallel,每卡一份完整模型、梯度 all-reduce)/ FSDP(把参数和优化器状态也切到各卡),循环本身不变。
工程视角 / 真实世界
学习率找法 :makemore Part 2 里 Karpathy 演示了一个朴素的 lr sweep——在指数刻度上扫一遍 lr,画出 loss 对 lr 的曲线,选下降最快又不炸的区域。大模型则依赖 scaling 实验和经验公式(第 18 课)。
大模型的 loss spike :训练中 loss 突然跳高,常见应对是回滚到之前的 checkpoint、跳过这批数据、降低 lr;OPT、BLOOM 等公开训练日志里都记录过这类事件。QK-Norm 等架构技巧(第 25 课)也是为了稳定性。
Checkpoint :定期保存权重 + 优化器状态 + 数据加载器位置 + RNG 状态,保证断点续训;优化器状态是权重的数倍大,是 checkpoint 存储和写入时间的大头。
💬 能聊的要点
「LLM 训练标配是 AdamW(β2=0.95、weight decay 0.1)+ warmup + cosine decay + 梯度裁剪 1.0;Adam 为每个参数维护一阶和二阶矩,实现逐参数自适应步长。」
「混合精度 + Adam 每个参数要约 16 字节(2+2+4+4+4),7B 模型光状态就 112GB,这是 ZeRO/FSDP 把优化器状态、梯度、参数切到多卡的直接动机。」
「预训练一般只跑约 1 个 epoch,基本不会过拟合,train/val loss 贴在一起;SFT 数据小、多 epoch,才是过拟合的高发区。」
常见误区
❌ 梯度累积和大 batch 是两回事 ✅ 数学上等价(前提是 loss 正确地除以累积步数),区别只在耗时和显存。
❌ Adam 的 weight decay 就是 L2 正则 ✅ 在 Adam 里两者不等价,AdamW 把 weight decay 与梯度解耦,这正是它被提出的原因。
❌ 推理和训练显存差不多 ✅ 推理约 2 bytes/参数(BF16 权重)+ KV Cache;训练约 16 bytes/参数 + activations。
自测(心算/口答即可)
每卡 micro-batch 为 8 条 × 4096 token,梯度累积 4 步,64 张卡数据并行,每步 global batch 是多少 token?答案 8 × 4096 × 4 × 64 = 8,388,608 ≈ 8.4M tokens。
用混合精度 + AdamW 训练 13B 模型,不算 activations 大约需要多少显存?答案 13B × 16 bytes ≈ 208GB,至少需要把状态切到 3 张以上的 80GB 卡上。
为什么要 warmup?答案 训练初期参数随机、梯度大且方向不稳,Adam 的二阶矩估计也还不准,直接用峰值 lr 容易发散;先用小 lr 过渡,让优化器统计量稳定下来。
延伸
🎬 Karpathy:Let's reproduce GPT-2 (124M) (后半段完整讲了 AdamW 超参、梯度裁剪、warmup + cosine、梯度累积、DDP,是本课的最佳配套)
🎬 Karpathy:Building makemore Part 2: MLP (train/dev/test 划分、lr sweep、过拟合的直观演示)
🎬 李沐:《动手学深度学习》第 11 章「优化算法」(动量法、Adam、学习率调度器)与第 4 章「权重衰减」「暂退法(Dropout)」
📄 Loshchilov & Hutter, 2019, Decoupled Weight Decay Regularization (AdamW,ICLR)
📄 Rajbhandari et al., 2020, ZeRO: Memory Optimizations Toward Training Trillion Parameter Models (16 bytes/参数的出处)
下一课 → 10 · Attention 的直觉:让每个 token 去「查询」其他 token
10 · Attention 的直觉:让每个 token 去「查询」其他 token
⏱ 10 分钟 | 前置:09 · Training Loop 全貌 | 难度:★☆☆
一句话
Attention(注意力)就是一次可微分的软查表 :每个 token 拿自己的 Query 去和所有 token 的 Key 算相似度,softmax 成权重,再对 Value 做加权平均——于是每个 token 都能按需从上下文里「拉」信息。
(「可微分」= 整个过程都是连续的加减乘除,梯度能流过去,所以能用第 3 课的 backprop 训练;「softmax」= 把一组任意实数变成和为 1 的正数权重,第 8 课讲过。)
为什么要关心
Part 1 的 MLP 语言模型只能看固定窗口(比如前 3 个 token),而且窗口里每个位置的作用是写死在权重里的。RNN(Recurrent Neural Network,循环神经网络:按顺序一个 token 一个 token 地读,把历史压进一个固定大小的「状态」向量往后传)能看更长,但信息要一步步传,远处的 token 很容易「被忘掉」,而且第 t 步必须等第 t−1 步算完,没法并行训练。Attention 同时解决了这两件事。后面所有 AI Infra 话题——KV Cache、FlashAttention、GQA、PagedAttention——优化的都是这一个操作,不懂它后面就全是黑盒。
核心直觉
1. 从「取平均」开始
Karpathy 在 Let's build GPT 里先做了一个最朴素的版本:让第 t 个 token 的表示 = 前 t 个 token 向量(即它们的 Embedding,第 7 课)的平均 。这已经比 bigram(只看前一个 token 来预测下一个的最简模型)强,因为它看到了全部历史;但问题也很明显——所有历史 token 权重一样,「the」和「cat」同等重要。
Attention 的改进只有一点:权重不再是均匀的 1/t,而是由内容决定的 。
2. 软字典:hash map 的连续版
后端工程师最熟悉的查表:
hash map: query == key → 返回 value(要么命中,要么不命中)
attention: similarity(query, key_i) → 权重 w_i → 返回 Σ w_i · value_i
Query(Q) :我想找什么(「我是一个代词,我在找我指代的名词」)
Key(K) :我能提供什么标签(「我是一个名词,单数,是动物」)
Value(V) :真被取走的内容(这个 token 的实际信息)
每个 token 同时扮演三个角色:自己发出一个 Query,也向别人暴露 Key 和 Value。Q/K/V 都是由同一个 token 的 Embedding 乘三个不同的矩阵得到,这三个矩阵是学出来的 ——模型自己决定「什么该匹配什么」。
🧩 难点拆解:Q/K/V 到底是什么,为什么要三个投影
先直觉 :一个 token 的 Embedding 向量 x 里什么信息都有(词义、词性、单复数……)。但「拿去匹配」和「被取走」需要的信息不一样:
匹配时,「it」关心的是「谁是单数名词」——这是 Key 空间 里的问题;
被取走时,「animal」要交出的是「它是一只动物、很累」——这是 Value 空间 里的内容。
所以用三个矩阵 W_q、W_k、W_v 把同一个 x「投影」(乘一个矩阵 = 换一组坐标轴,只保留/组合关心的方向)成三个不同用途的向量。类比数据库:同一行记录,建索引用的列(Key)和 SELECT 出来的列(Value)通常不是同一组。
再看小例子 (数字为示意,2 维):
q(it) = [1, 0] "我在找单数名词"
k(animal) = [2, 0] "我是单数名词" q·k = 1×2 + 0×0 = 2
k(street) = [0, 1] "我是地点" q·k = 0
softmax([2, 0]) ≈ [0.88, 0.12]
新 it = 0.88 × v(animal) + 0.12 × v(street)
点积(dot product,对应位相乘再相加)大 = 两个向量方向接近 = 匹配。softmax 把分数变成权重,最后按权重混合的是 v ,不是 k。
一句话总结 :Q 是「搜索词」、K 是「索引」、V 是「文档正文」,三者都从同一个 token 投影出来,投影矩阵在训练中学到「按什么匹配、交出什么」。
类比不成立的地方:数据库索引是人定义的、精确匹配;这里的「索引」是学出来的、连续的,而且每一行都会被所有 Query 按比例读一点。
3. 一个例子
句子: The animal didn't cross the street because it was too tired
"it" 的 Query ──┬── · K(The) → 0.02
├── · K(animal) → 0.61 ◀ 高分
├── · K(street) → 0.09
└── ...
softmax 后的权重
新的 "it" = 0.61·V(animal) + 0.09·V(street) + ...
(数字是示意)经过这一步,「it」的向量里混入了「animal」的信息,下一层再处理时它就「知道」自己指谁了。把 tired 换成 wide,理想情况下权重会转向 street——这是 context-dependent 的,MLP 的固定窗口做不到。
4. Attention 是一种「通信」机制
Karpathy 的说法很适合工程师:把序列看成一个有向图 ,每个 token 是一个节点,Attention 就是节点之间按内容决定的消息传递。
Causal (GPT),箭头 = 「可以读取」:
t1 读 {t1}
t2 读 {t1, t2}
t3 读 {t1, t2, t3}
t4 读 {t1, t2, t3, t4} 每个节点只能收「过去和自己」的消息
Bidirectional (BERT):每个 t_i 都读 {t1, t2, t3, t4}
由此推出几个关键性质:
没有空间概念 :Attention 本身只看内容,不知道谁在前谁在后 → 需要 Positional Encoding(位置编码,第 13 课)
batch 之间不通信 :不同样本的 token 永远不互相看
Causal mask (因果掩码:把「未来」位置屏蔽掉):生成模型里未来 token 不能被看到,否则训练时就是「偷看答案」(第 11 课)
5. 和 RNN / CNN 对比
符号:T = 序列长度(token 数,如 4096),d = 每个 token 向量的维度(如 4096),k = CNN 卷积窗口大小。「路径长度」= 第 1 个 token 的信息要经过几步计算才能到达第 T 个 token,步数越多信息越容易丢。
RNN
CNN
Self-Attention
任意两 token 间路径长度
O(T)
O(log T) 或 O(T/k)
O(1)
训练时能否并行
否(按时间步)
能
能
每层计算量
O(T·d²)
O(k·T·d²)
O(T²·d)
代价就在最后一行:Attention 要算「每个 token × 每个 token」的 T×T 个分数,每个分数是一次 d 维点积,所以是 T²·d。序列长度 T 的平方 ——T 翻倍,这部分计算翻 4 倍。这也是长上下文昂贵、FlashAttention/滑动窗口/线性注意力存在的根本原因。
6. 历史一笔
Attention 最早(Bahdanau et al., 2014)是给 RNN 翻译模型用的:解码器每生成一个词,回头「看」一眼源句子。2017 年 Attention Is All You Need 的突破是:把 RNN 整个扔掉,只用 Attention ,从而获得完全并行的训练——这才让模型能在 GPU 上扩展到今天的规模。
代码片段
Karpathy 的「数学小技巧」:用下三角矩阵乘法一次算完所有位置的「对过去加权平均」。
import torch
import torch.nn.functional as F
T, C = 4, 2 # 4 个 token,每个 2 维
x = torch.randn(T, C) # 每行是一个 token 的向量
# version 1: 均匀平均 —— 权重写死
w = torch.tril(torch.ones(T, T)) # 下三角:只能看自己和过去
w = w / w.sum(1, keepdim=True) # 每行归一化成 1/t
out_avg = w @ x # (T,T) @ (T,C) → (T,C)
# version 2: 权重来自内容 —— 这就是 attention 的雏形
scores = x @ x.T # 两两相似度 (T,T)
scores = scores.masked_fill(torch.tril(torch.ones(T, T)) == 0, float('-inf'))
w = F.softmax(scores, dim=-1) # -inf 位置权重变 0
out_attn = w @ x # 按相似度加权平均
torch.tril(lower triangle,取下三角)生成「第 i 行只有前 i 列是 1」的矩阵;masked_fill(-inf) 把为 0 的位置分数填成 -inf。因为 exp(-inf)=0,softmax 后未来位置的权重恰好是 0。这是 causal mask 的标准写法。
w @ x:权重矩阵的第 i 行 × 所有 token 向量 = 第 i 个 token 的加权平均。一次矩阵乘就同时算完了所有位置,这就是「并行」的来源。
version 1 和 version 2 的区别只在权重矩阵 w 怎么来 ;后面的 w @ x 完全一样。
version 2 还缺三样东西:Q/K/V 三个投影、除以 √d 的缩放、多头——下两课补齐。
工程视角 / 真实世界
T² 的账 :T=8K 时,一个 head 的注意力分数矩阵有约 6700 万(8192²)个元素;朴素实现要把它写回显存,这就是 FlashAttention 要消灭的开销(第 40 课)。
KV Cache 的来源 :生成第 t+1 个 token 时,前 t 个 token 的 K 和 V 不会变(causal),可以缓存下来复用,只需为新 token 算一个 Q。这就是推理系统里最大的显存消耗项(第 37 课)。
Prefill vs Decode :Prefill(处理 prompt 阶段)的所有 token 可以一次性并行算 Attention(矩阵乘,算力密集);Decode(逐个生成阶段)每次只生成 1 个 token(向量-矩阵乘,带宽密集:搬数据的时间多于算的时间)。这个不对称性决定了整个推理系统的设计(第 36 课)。
💬 能聊的要点
「Attention 本质是一个内容寻址的软查表:Q 和 K 算相似度决定权重,V 是被取走的内容;Q/K/V 投影是学出来的,所以模型自己学会『什么该关注什么』。」
「相对 RNN,Attention 把任意两个 token 之间的路径长度降到 O(1) 且训练可并行,代价是计算和显存随序列长度平方增长——长上下文优化基本都在跟这个 T² 较劲。」
「Attention 本身是置换不变(permutation-invariant,打乱输入顺序、输出只是跟着打乱)的,不知道顺序,所以 Transformer 必须额外注入位置信息;GPT 的 causal mask 则保证每个位置只能看过去,这也是 KV Cache 能成立的前提。」
常见误区
❌ Attention 权重就是模型的「解释」,高权重说明模型认为它重要 ✅ 权重只反映一层一个 head 里的信息路由,多层叠加、残差、FFN 都会改变信息流;用 attention map 做可解释性要非常谨慎。
❌ Attention 是 Transformer 发明的 ✅ 2014 年就用在 RNN 翻译模型上了;Transformer 的贡献是「只用 Attention」,去掉了循环结构。
❌ Self-Attention 里 token 只和别的 token 交互 ✅ 每个 token 也会 attend 到自己(对角线),而且常常给自己很大的权重。
自测(心算/口答即可)
为什么 causal mask 用 -inf 而不是 0 来屏蔽?答案 mask 加在 softmax 之前的分数上。填 0 的话 exp(0)=1,未来位置仍会拿到权重;填 -inf 则 exp(-inf)=0,softmax 后权重恰好为 0,且其余权重仍自动归一化到 1。
序列长度从 4K 变成 32K,单层 attention 分数矩阵的元素数变成原来的几倍?答案 长度 ×8,元素数 ×64(平方关系)。
一个 batch 里的两条样本会互相 attend 吗?答案 不会。Attention 在每条样本的 (T,T) 矩阵内部计算,batch 维度只是并行。(推理系统把多个请求拼进同一个 batch 时,也要靠 mask/变长 kernel 保证它们互不可见。)
延伸
🎬 Karpathy:Let's build GPT: from scratch, in code, spelled out. 中「the mathematical trick in self-attention」与「self-attention」几节,以及他对 attention as communication 的几条 note
🎬 李沐:《动手学深度学习》zh.d2l.ai 第 10 章「注意力机制」(10.1 注意力提示、10.3 注意力评分函数);「Transformer 论文逐段精读」
📄 Bahdanau et al., Neural Machine Translation by Jointly Learning to Align and Translate (2014);Vaswani et al., Attention Is All You Need (2017)
下一课 → 11 · Self-Attention 代码拆解:Q/K/V、缩放、Causal Mask
11 · Self-Attention 代码拆解:Q/K/V、缩放、Causal Mask
⏱ 10 分钟 | 前置:10 · Attention 的直觉 | 难度:★★☆
一句话
Attention(Q,K,V) = softmax(QKᵀ/√d_k + mask) · V——三个 Linear 投影、一次矩阵乘算相似度、除以 √d_k 防止 softmax 饱和、加 causal mask 屏蔽未来、再一次矩阵乘取 Value,就是 GPT 里 Self-Attention 的全部。
为什么要关心
这一行公式是整个 LLM 世界的「热点函数」:训练和推理的大部分 attention 相关优化(FlashAttention、KV Cache、GQA、PagedAttention)都在改写它的某一部分。把每个张量的 shape 和每一步的动机弄清楚,后面读 vLLM / SGLang 的 kernel 接口时,看到 q, k, v, causal=True, softmax_scale 这些参数就能对上号。
核心直觉
1. 三个投影:同一个 x,三种角色
输入 x 形状 (B, T, C):B 条样本(batch size)、每条 T 个 token、每个 token 是 C 维向量(C 即 d_model,GPT-2 small 为 768)。d_k 是 Q/K 向量的维度,d_v 是 V 的维度,单头时通常都等于 C,多头时是 C/头数(GPT-2 为 64,下一课讲)。
x ──► W_q ──► Q (B,T,d_k) "我在找什么"
├─► W_k ──► K (B,T,d_k) "我能被怎么找到"
└─► W_v ──► V (B,T,d_v) "被找到后交出什么"
为什么不直接用 x · xᵀ(上一课 version 2)?三个原因,一步步看:
相似度会是对称的 :x_A·x_B = x_B·x_A,A 对 B 的关注度必然等于 B 对 A。但语言里关系是有方向的:代词要找名词,不代表名词也要找代词。用 q_A·k_B = (x_A W_q)·(x_B W_k) 后,两个方向用的是不同矩阵,自然不对称。
自己总是最像自己 :x·x 是向量长度的平方,往往最大,于是每个 token 最关注的都是自己。投影后 q_i·k_i 不再有这个保证。
「匹配」和「被取的内容」被绑死 :W_v 让交出的内容可以和用来匹配的特征不同(见上一课的 🧩)。
Self -Attention 指 Q、K、V 都来自同一个序列;Cross -Attention 指 Q 来自一个序列(如翻译的 decoder),K/V 来自另一个(encoder 输出)。GPT 类 decoder-only 模型只有 Self-Attention。
2. 打分:QKᵀ
Q @ K.transpose(-2,-1) 得到 (B, T, T):第 i 行第 j 列 = token i 的 Query 与 token j 的 Key 的点积。点积越大 → 越相关。transpose(-2,-1) 把 K 的最后两维 (T, d_k) 转成 (d_k, T),这样 (T, d_k) @ (d_k, T) 正好一次算出所有 T×T 对的点积。
3. 🧩 难点拆解:为什么除以 √d_k
先直觉 :点积是 d_k 项相加。项越多,和的波动越大——就像掷 64 个骰子求和,总分的波动一定比掷 1 个大。分数波动一大,softmax 就会变成「赢家通吃」,模型学不动。除以 √d_k 就是把波动缩回和维度无关的水平。
因果链 :
假设初始化时 q、k 的每个分量独立、均值 0、方差(数值偏离均值的平方的平均,衡量「波动」)为 1。
每一项 q_i·k_i 的方差也是 1;d_k 个独立项相加,方差就相加 → 点积方差 = d_k ,标准差 = √d_k。
d_k=64 时标准差 = 8,分数经常出现 ±10、±20 这样的值。
softmax 里是 exp(分数):exp(18) ≈ 6.6 千万,exp(2) ≈ 7.4,差距被指数放大 → 输出接近 one-hot(一个 1、其余全 0)。
softmax 输出接近 one-hot 时,输入稍微变一点输出几乎不变 → 梯度≈0,这叫饱和 ;梯度为 0,参数就不更新。
除以 √d_k = 8,方差回到 1,分数落在 ±2 左右,softmax 保持「软」,梯度正常。
小例子 (d_k=64,数字为示意):
scores (无缩放): [ 18.0, 2.0, -5.0] → softmax ≈ [1.00, 0.00, 0.00] 梯度几乎为 0
scores / 8: [ 2.25, 0.25, -0.63] → softmax ≈ [0.84, 0.11, 0.05] 还能学
一句话总结 :除以 √d_k 不是为了「归一化结果」,而是为了让 softmax 的输入方差不随 head 维度增大,避免饱和、保住梯度。
Karpathy 视频里专门有一节「"scaled" self-attention」讲这个:初始化时 softmax 太尖锐,每个 token 就只会盯着一个邻居。
4. Causal Mask:训练时不许偷看
GPT 训练时一条长度 T 的序列同时产生 T 个预测任务:用 t1 预测 t2,用 t1..t2 预测 t3……如果 token i 能看到 i+1,它直接「抄答案」,loss 很低但毫无用处。
mask (T=4) 1 = 可见, 0 = 填 -inf
k1 k2 k3 k4
q1 [ 1 0 0 0 ]
q2 [ 1 1 0 0 ]
q3 [ 1 1 1 0 ]
q4 [ 1 1 1 1 ]
🧩 难点拆解:Causal Mask 怎么起作用
先直觉 :mask 不是「删掉」未来 token,而是在 softmax 之前把它们的分数改成 -inf,让它们在 softmax 里分到的权重恰好为 0,同时剩下的权重自动重新凑成 1。
小例子 (T=3,看第 2 行,即 q2 这一行):
原始分数 q2·k: [ 1.0, 2.0, 3.0 ] k3 是未来,不许看
加 mask: [ 1.0, 2.0, -inf ]
exp: [ 2.72, 7.39, 0 ]
softmax: [ 0.27, 0.73, 0 ] 和为 1,k3 权重为 0
对比错误做法「先 softmax 再把 k3 乘 0」:softmax([1,2,3]) ≈ [0.09, 0.24, 0.67],乘 0 后剩 [0.09, 0.24, 0],和只有 0.33,而且 k3 的分数已经影响了前两个的比例。
为什么这让训练能并行 :
一条长度 T 的序列要产生 T 个训练样本(「看 t1 猜 t2」「看 t1..t2 猜 t3」……)。
不加 mask,每个样本得单独跑一次前向(输入长度不同)。
加了下三角 mask 后,第 i 行天然只看到前 i 个 token,正好等价于「第 i 个样本」。
所以一次前向 、一次矩阵乘就同时算完 T 个样本的预测——RNN 做不到,因为它的第 i 步必须等第 i−1 步。
一句话总结 :causal mask = softmax 前把上三角填 -inf,让一条序列在一次前向里变成 T 个互不偷看的训练样本。
BERT 这类 encoder 不加 causal mask(双向),只 mask 掉 padding(为了凑齐 batch 长度补的空白 token)。
5. 加权求和:@ V
softmax(scores) @ V:(B,T,T) @ (B,T,d_v) → (B,T,d_v)。每个输出 token 是所有可见 token 的 Value 按权重混合。
代码片段
单头 causal self-attention(接近 Karpathy 视频里的 Head 类),关键处标注 shape:
import torch, torch.nn as nn, torch.nn.functional as F
class Head(nn.Module):
def __init__(self, n_embd, head_size, block_size, dropout=0.0):
super().__init__()
self.key = nn.Linear(n_embd, head_size, bias=False)
self.query = nn.Linear(n_embd, head_size, bias=False)
self.value = nn.Linear(n_embd, head_size, bias=False)
# buffer:不是参数,不参与训练,但会跟着 .to(device)
self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
self.dropout = nn.Dropout(dropout)
def forward(self, x): # x: (B, T, C)
B, T, C = x.shape
k = self.key(x) # (B, T, hs)
q = self.query(x) # (B, T, hs)
v = self.value(x) # (B, T, hs)
att = q @ k.transpose(-2, -1) # (B, T, T)
att = att * k.shape[-1] ** -0.5 # scale by 1/sqrt(hs)
att = att.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
att = F.softmax(att, dim=-1) # 每行和为 1
att = self.dropout(att) # 随机丢掉一些「连接」
return att @ v # (B, T, hs)
k.shape[-1] ** -0.5 就是 1/√d_k(这里 hs=head_size 即 d_k);nn.Linear(n_embd, head_size, bias=False) 就是 W_k 等投影矩阵,形状 C×hs。
self.tril[:T, :T]:buffer 按最大长度 block_size(模型能处理的最大上下文长度)预分配,推理时序列更短就切片。
dropout:训练时随机把一部分权重置 0,是防过拟合的正则化手段,推理时关闭。
中间张量 att 是 (B, T, T)——显存随 T² 增长的就是它。
生产代码不会手写这几行,而是调用 PyTorch 的融合算子:
y = F.scaled_dot_product_attention(q, k, v, is_causal=True, dropout_p=0.0)
「融合算子」(fused kernel)= 把 matmul、scale、mask、softmax、matmul 几步合成一个 GPU 函数,中间结果不写回显存。nanoGPT 在 PyTorch ≥ 2.0 时就走这条路径(self.flash = hasattr(F, 'scaled_dot_product_attention')),底层可分派到 FlashAttention 等 kernel,不再物化 (T,T) 矩阵。
工程视角 / 真实世界
原论文配置 :Attention Is All You Need base 模型 d_model=512、8 个 head、每个 head d_k=d_v=64。GPT-2 small 同样是 d_head=64(768/12),Llama 系列多为 128。
Mask 的变体 :推理系统里 mask 远不止下三角——continuous batching 把多个请求拼在一起、prefix caching 共享前缀、sliding window 只看最近 W 个 token,都体现在 kernel 的 mask / 变长索引上(如 FlashAttention 的 cu_seqlens、window_size 参数)。
Decode 阶段不需要 mask :一次只算 1 个新 token 的 Q,它本来就在序列最后,所有缓存的 K/V 都是「过去」。Q 的形状变成 (B, 1, d),分数矩阵 (B, 1, T)。每步都要把全部 T 个缓存的 K/V 从显存读出来,却只和 1 个 Query 做点积——读的数据多、算的少,这就是 decode 是访存密集型(memory-bound)的原因。
softmax_scale :FlashAttention / vLLM 的 attention 接口都有这个参数,默认就是 1/√head_dim;少数模型会显式改成别的值(例如长上下文扩展时额外乘一个系数),所以接口必须留这个口子。
💬 能聊的要点
「除以 √d_k 是因为 QKᵀ 点积的方差随 d_k 线性增长,不缩放 softmax 会饱和成 one-hot、梯度消失;这是一个纯数值稳定性的设计。」
「Causal mask 让 decoder 在一次前向里并行训练序列上所有位置的 next-token 预测,这是 Transformer 训练效率远高于 RNN 的关键;推理 decode 时每步只有一个 Query,反而不需要 mask。」
「朴素实现要物化 B×H×T×T 的分数矩阵,显存 O(T²);生产上用 scaled_dot_product_attention / FlashAttention 分块计算,数学结果一样,但不写回完整矩阵。」
常见误区
❌ √d_k 缩放是为了让结果「归一化」 ✅ softmax 本身已经归一化;缩放是为了控制 logits 的方差,避免 softmax 饱和导致梯度消失。
❌ Causal mask 是把未来位置的权重乘以 0 ✅ 是在 softmax 之前 把分数设为 -inf;若 softmax 之后再乘 0,剩下的权重和就不再是 1。
❌ Q/K/V 是三份不同的输入 ✅ 在 Self-Attention 里它们来自同一个 x,只是乘了三个不同的学习矩阵(实现上常合并成一个 3C 宽的 Linear)。
自测(心算/口答即可)
B=8, T=1024, 单头 head_size=64,att 张量有多少个元素?如果是 fp32 占多少显存?答案 8×1024×1024 ≈ 8.4M 个元素,×4 字节 ≈ 32 MiB。这只是一层一个 head;12 层×12 head 就要乘 144——这就是为什么需要 FlashAttention。
为什么 BERT 不需要 causal mask,GPT 需要?答案 BERT 的训练目标是完形填空(Masked LM),输入里被遮住的词本身已经替换成 [MASK],双向看上下文不会泄漏答案;GPT 的目标是预测下一个 token,看到未来就等于看到答案。
head_size 从 64 改成 128,不做 √d_k 缩放的话,初始化时分数的标准差大约变成多少?答案 方差 = d_k = 128,标准差 ≈ 11.3(原来是 8)。
延伸
🎬 Karpathy:Let's build GPT 中「self-attention」「note 6: "scaled" self-attention」「inserting a single self-attention block」几节
🎬 李沐:zh.d2l.ai 第 10.3 节「注意力评分函数」(缩放点积注意力)、10.6 节「自注意力和位置编码」;「Transformer 论文逐段精读」中 Scaled Dot-Product Attention 一段
📄 Attention Is All You Need §3.2.1;PyTorch 文档 torch.nn.functional.scaled_dot_product_attention;nanoGPT model.py 中 CausalSelfAttention
下一课 → 12 · Multi-Head Attention
12 · Multi-Head Attention:把一次注意力拆成多路并行
⏱ 10 分钟 | 前置:11 · Self-Attention 代码拆解 | 难度:★★☆
一句话
Multi-Head Attention(MHA)把 d_model 维(每个 token 向量的长度,GPT-2 small 为 768)切成 h 份(h = n_head,头数,GPT-2 small 为 12),每份独立做一次 attention(各自有 Q/K/V),再拼回来过一个输出投影 W_O——参数量和计算量与单头几乎相同 ,但模型可以同时关注多种不同的关系。
为什么要关心
单个 head 每个位置只输出一组 softmax 权重,只能表达「一种」关注模式。而语言里一个 token 同时需要很多种信息:语法上的主语、指代对象、前一个 token、同一个括号的另一半……MHA 让这些模式分头进行。更重要的是,「head」是后面 MQA/GQA/MLA(第 19–20 课)、Tensor Parallel 切分(第 30 课)、KV Cache 显存账本(第 37 课)的基本单位——n_head、n_kv_head、head_dim 这三个数你会反复看到。
核心直觉
1. 一个委员会,而不是一个专家
类比:代码评审。一个人看一遍(单头),他的注意力只能集中在一处;12 个评审各看一个方面(安全、性能、命名……),最后汇总成一个结论(W_O)。每个评审看的维度更窄(d_head = d_model / h),总工作量不变。
2. 切分,不是复制
x (B,T,768)
│ c_attn: 一个 768→2304 的 Linear,一次算出 Q,K,V
▼
Q,K,V 各 (B,T,768)
│ view + transpose: 768 = 12 heads × 64
▼
Q,K,V 各 (B,12,T,64) ← 12 个 head 当成额外的 batch 维
│ 每个 head 独立: softmax(QKᵀ/√64 + mask) V
▼
Y (B,12,T,64)
│ transpose + view 拼回
▼
Y (B,T,768) ── c_proj (W_O, 768→768) ──► 输出 (B,T,768)
关键点:
head 之间在 attention 计算时互不通信 ,完全并行——实现上就是把 head 当成 batch 维做批量矩阵乘。
W_O 负责「汇总」 :拼接后的向量只是 12 段各自为政的结果,W_O 把它们线性混合,写回 residual stream(贯穿所有层的主干向量 x,每层读它、再把结果加回去,第 14 课详讲)。
参数账 :W_Q、W_K、W_V、W_O 各 d×d,共 4d² (不计 bias),与 head 数无关。h=1 或 h=12,参数一样多——因为 12 个 head 各自的 W_Q(768×64)横向拼起来正好就是一个 768×768 的矩阵。
🧩 难点拆解:heads 怎么切、怎么拼(shape 走一遍)
先直觉 :「切头」不做任何计算,只是换个角度看同一块内存——把一个 token 的长向量按段分给不同 head,就像把一行 8 列的表按列分给 2 个 worker 处理。
小例子 :d_model=8,h=2 → head_dim=4;B=1,T=3。
q (B,T,C) = (1,3,8) token1 的 q: [a0 a1 a2 a3 | a4 a5 a6 a7]
└─ head 0 ─┘ └─ head 1 ─┘
view(B,T,h,hs) → (1,3,2,4) 把 8 拆成 2×4,数据一个字节没动
transpose(1,2) → (1,2,3,4) head 维挪到 T 前面:现在是「2 个 head,每个 head 有 3 个 token × 4 维」
attention → 分数 (1,2,3,3):每个 head 一张独立的 3×3 分数表
→ 输出 y (1,2,3,4)
transpose(1,2) → (1,3,2,4) 把 head 维挪回去
view(B,T,C) → (1,3,8) token1 的 y: [head0 的 4 维 | head1 的 4 维] 首尾拼接
c_proj (8→8) → (1,3,8) W_O 把两段混合
为什么要 transpose?PyTorch 的批量矩阵乘只对最后两维做乘法、前面都当 batch。把形状整理成 (B, h, T, hs) 后,q @ kᵀ 就自动变成「B×h 个独立的 (T,hs)@(hs,T)」。
一句话总结 :切头 = view 拆最后一维 + transpose 把 head 挪到 batch 位置;拼头 = 反过来再 view 回去;真正让 head 之间交流的只有最后的 W_O。
3. 为什么「多个小头」比「一个大头」好
表达力 :单头每个位置只能产出一个概率分布,输出是 Value 的一个凸组合(系数非负、和为 1 的加权平均)。想同时「60% 看主语 + 40% 看前一个词」还行,但这样主语信息只拿到 0.6 份、还和前一个词的信息混在同一组维度里;想「完整拿到主语信息」和「完整拿到前一个词信息」就做不到。多头时 head 0 可以 100% 看主语、head 1 可以 100% 看前一个词,各自写进拼接向量的不同段,互不稀释。
可观察的分工 :可解释性研究发现了不同功能的 head,例如 Anthropic 的 induction heads (在上下文中找到「上次出现 A 之后是 B」,于是这次也预测 B,被认为是 in-context learning 的重要机制之一),以及只看前一个 token 的 previous-token head。
但也有冗余 :Are Sixteen Heads Really Better than One? (Michel et al., 2019)发现很多 head 在测试时可以剪掉而性能影响不大——多头更多是帮助训练,推理时并非每个都必需。
4. head 数怎么选
实践上固定 head_dim(64 或 128),再用 n_head = d_model / head_dim。head_dim 太小,每个 head 的 QKᵀ 表达力不够;太大则 head 数少。现代大模型几乎都用 head_dim=128,一个原因是它对 GPU Tensor Core(GPU 上专做小块矩阵乘的硬件单元,维度是 8/16 的倍数时效率最高)和 FlashAttention kernel 友好。
模型
d_model
n_head
head_dim
Transformer base (2017)
512
8
64
GPT-2 small (124M)
768
12
64
GPT-3 175B
12288
96
128
Llama-2-7B
4096
32
128
代码片段
nanoGPT CausalSelfAttention 的核心(略删减):
class CausalSelfAttention(nn.Module):
def __init__(self, cfg):
super().__init__()
self.n_head, self.n_embd = cfg.n_head, cfg.n_embd
self.c_attn = nn.Linear(cfg.n_embd, 3 * cfg.n_embd) # Q,K,V 合并成一个矩阵乘
self.c_proj = nn.Linear(cfg.n_embd, cfg.n_embd) # W_O
def forward(self, x):
B, T, C = x.shape
q, k, v = self.c_attn(x).split(self.n_embd, dim=2) # 各 (B,T,C)
hs = C // self.n_head
# (B,T,C) -> (B,T,nh,hs) -> (B,nh,T,hs):head 变成 batch 维
q = q.view(B, T, self.n_head, hs).transpose(1, 2)
k = k.view(B, T, self.n_head, hs).transpose(1, 2)
v = v.view(B, T, self.n_head, hs).transpose(1, 2)
y = F.scaled_dot_product_attention(q, k, v, is_causal=True) # (B,nh,T,hs)
y = y.transpose(1, 2).contiguous().view(B, T, C) # 拼回 (B,T,C)
return self.c_proj(y)
c_attn 输出宽度 3*C:把三个 Linear 合并成一个大 GEMM(General Matrix Multiply,通用矩阵乘,GPU 上最核心的算子),一次启动、矩阵更大,GPU 利用率更高;.split(C, dim=2) 再把 2304 宽的结果切回三段 768——HF/vLLM 里常叫 qkv_proj 或 fused QKV。
view + transpose(1,2) 是「切头」的全部操作,没有任何计算,只是换个视角看同一块内存;.contiguous() 是因为 transpose 后内存不连续,view 前要整理一下。
Karpathy 视频里先写了「多个 Head 实例 + torch.cat」的朴素版,再在 nanoGPT 里改成这种批量版——两者数学等价。
工程视角 / 真实世界
KV Cache 以 head 为单位 :每层每 token 要缓存 2 × n_kv_head × head_dim 个数。MHA 下 n_kv_head = n_head,这正是 MQA(所有 head 共享 1 组 KV)和 GQA(分组共享)要砍的地方——Llama-2-70B、Llama 3 全系都用了 GQA(第 19 课)。
Tensor Parallel 按 head 切 :Tensor Parallel(TP,把一层的权重矩阵切到多张 GPU 上一起算)在 Megatron-LM 风格下把 attention 的 head 分到不同 GPU,每张卡算自己那几个 head,W_O 按行切,最后一次 all-reduce(所有卡把各自的部分结果求和、并人手一份结果的集合通信)汇总。所以 n_head(以及 GQA 下的 n_kv_head)通常要能被 TP size 整除,否则就得复制 KV head。
FLOPs 不变 :QKᵀ 的计算量是 h × T² × d_head = T² × d_model,与 h 无关。多头的「额外代价」几乎只有 reshape 和更小的矩阵乘带来的 kernel 效率差异。
💬 能聊的要点
「MHA 不是把注意力复制 h 份,而是把 d_model 切成 h 个子空间,参数量始终是 4d²、FLOPs 也不变;收益是一个位置能同时维持多种独立的关注分布。」
「head 在 attention 计算里互不通信、天然并行,实现上就是把 head 当成 batch 维;所以 Tensor Parallel 最自然的切法就是按 head 分卡,最后靠 W_O 之后的 all-reduce 汇总。」
「KV Cache 的大小正比于 n_kv_head × head_dim,MHA 下每个 query head 都有自己的 KV;MQA/GQA 就是在保留 query head 数的同时减少 KV head,直接把 KV Cache 缩小 n_head/n_kv_head 倍。」
常见误区
❌ head 越多模型越大 ✅ 在 d_model 固定时,head 数只改变切分方式,参数量不变。
❌ 每个 head 用完整的 d_model 维做 attention ✅ 每个 head 只在 d_model/h 维的子空间里算,这就是「总计算量不变」的原因。
❌ 多头的输出是 h 个结果取平均 ✅ 是拼接(concat)后再乘 W_O,W_O 是可学习的混合,不是平均。
自测(心算/口答即可)
GPT-2 small(d=768,12 层)所有 attention 层的权重矩阵参数量大约多少(不计 bias)?答案 每层 4d² = 4 × 589,824 ≈ 2.36M,×12 层 ≈ 28.3M。
把 d_model=4096 的模型从 32 个 head 改成 64 个 head,head_dim 变成多少?QKᵀ 的总 FLOPs 变吗?答案 head_dim 从 128 变成 64;总 FLOPs 不变(h × d_head 恒等于 d_model)。
为什么 view 之前要 .contiguous()?答案 transpose 只改 stride(每一维前进 1 格要在内存里跳过多少个元素)不搬数据,得到的张量内存不连续;view 要求连续内存才能重新解释形状,所以先 contiguous() 拷贝一次(或者用 reshape,它会在需要时自动拷贝)。
延伸
🎬 Karpathy:Let's build GPT 中「multi-headed self-attention」一节;nanoGPT model.py 的 CausalSelfAttention
🎬 李沐:zh.d2l.ai 第 10.5 节「多头注意力」;「Transformer 论文逐段精读」中 Multi-Head Attention 一段
📄 Attention Is All You Need §3.2.2;Olsson et al., In-context Learning and Induction Heads (Anthropic, 2022);Michel et al., Are Sixteen Heads Really Better than One? (2019)
下一课 → 13 · Positional Encoding:从 Sinusoidal 到 RoPE
13 · Positional Encoding:从 Sinusoidal 到 RoPE
⏱ 10 分钟 | 前置:12 · Multi-Head Attention | 难度:★★☆
一句话
Attention 天生不知道顺序,必须额外注入位置信息;主流做法从「在输入上加一个位置向量」(Sinusoidal / Learned Absolute)演进到「在每层把 Q、K 按位置旋转一个角度」(RoPE),让 attention 分数只依赖相对距离 。
为什么要关心
去掉位置信息,「狗咬人」和「人咬狗」对 attention 来说是同一个 token 集合。位置编码的选择直接决定了模型能不能处理比训练时更长的上下文(这叫「外推」,extrapolation)——今天各家「支持 128K / 1M 上下文」背后,几乎都是在 RoPE 上做文章(PI、NTK、YaRN,第 23 课)。在 HF 的 config.json 里看到 rope_theta、rope_scaling、max_position_embeddings,都得靠这一课读懂。
核心直觉
1. 问题:Attention 是置换不变的
回忆第 10 课:Attention 是「对一组 Value 按相似度加权求和」。加权求和和加法一样不在乎顺序(1+2+3 = 3+1+2)。把输入 token 顺序打乱,每个 token 得到的输出只是跟着一起打乱,内容完全一样——它处理的是集合 ,不是序列 。(causal mask 其实泄漏了一点顺序信息,但远远不够。)
例子:「狗 咬 人」里「咬」对「狗」和「人」的分数只取决于这两个词的向量内容,和它们在左边还是右边无关——于是模型分不出谁咬谁。
2. 方案一:Absolute Position Embedding(加在输入上)
x_t = tok_emb[token_t] + pos_emb[t] 只在第 0 层输入处加一次
Sinusoidal (原始 Transformer):pos_emb 是固定公式,PE(t, 2i) = sin(t / 10000^(2i/d)),PE(t, 2i+1) = cos(...),其中 t 是位置、d 是 d_model、i 是第几对维度(0 到 d/2−1)。大白话:每一对维度是一个不同频率的「时钟指针」,低维转得快、高维转得慢,组合起来给每个位置一个唯一的「时间戳」。波长从 2π 到 10000·2π 几何级数分布。
Learned (GPT-2、BERT):pos_emb 就是一个 nn.Embedding(max_len, d),和 token embedding 一样学出来。GPT-2 是 1024 个位置,BERT 是 512。
两者效果在原论文里差不多。共同缺点:
位置信息只在输入处注入一次,要靠残差一路带到深层;
编码的是绝对 位置,而语言更关心相对距离(「前一个词」比「第 537 个词」更有意义);
Learned 版本训练时没见过位置 1025,完全无法外推 。
3. 方案二:Relative 位置(加在 attention 分数上)
T5 给 attention 分数加一个按相对距离分桶的可学习标量 bias;ALiBi(Press et al., 2021;BLOOM、MPT 使用)更简单,直接给分数减去 m × |i − j|(i、j 是两个 token 的位置),距离越远惩罚越大,每个 head 斜率 m 不同。例如 m=0.5 时,相邻 token 分数 −0.5,隔 10 个 token 分数 −5。它们编码相对距离,但把位置和内容分开 处理。
4. 方案三:RoPE —— 用旋转编码位置
RoPE(Rotary Position Embedding,Su et al., 2021,RoFormer)的想法:
把 q、k 的每两维看成一个 2D 平面上的向量,位置 m 的 token 就把它旋转 m·θ_i 角度。
pair i 的旋转频率: θ_i = base^(-2i/d) base 通常 10000,d 这里是 head_dim
位置 m 的 q: ●──► 旋转 m·θ 位置 n 的 k: ●──► 旋转 n·θ
╲ ╱
点积只取决于两者夹角差 (n−m)·θ
符号:m、n 是 query 和 key 所在的位置;i 是第几对维度(head_dim=128 就有 64 对);θ_i 是这对维度「每前进一个位置转多少弧度」。例如 head_dim=4 时只有 2 对:θ_0 = 10000⁰ = 1 弧度/位置(转得快),θ_1 = 10000^(-1/2) = 0.01 弧度/位置(转得慢)。
关键性质:两个旋转过的向量做点积,结果只和角度差有关 ,即 ⟨R_m q, R_n k⟩ = ⟨q, R_{n−m} k⟩(R_m 表示「旋转 m·θ 角」,⟨·,·⟩ 是点积)。
🧩 难点拆解:为什么「旋转」能给出相对位置
先直觉 :两根时钟指针的点积 = 长度 × 长度 × cos(夹角)。把两根指针一起 转任意角度,夹角不变,点积也不变。RoPE 让位置 m 的 q 转 m 格、位置 n 的 k 转 n 格;如果两个 token 都往后挪 100 个位置,两根指针各多转 100 格,夹角还是 (n−m) 格——所以分数只认「相距多远」。
小例子 (只看一对维度,θ=30°/位置,q = k = [1, 0]):
q 在位置 1 → 转 30° → [0.87, 0.50]
k 在位置 3 → 转 90° → [0.00, 1.00]
点积 = 0.87×0 + 0.50×1 = 0.50 = cos(60°) 夹角 = (3−1)×30° = 60°
q 在位置 5 → 转 150° → [-0.87, 0.50]
k 在位置 7 → 转 210° → [-0.87, -0.50]
点积 = 0.75 − 0.25 = 0.50 = cos(60°) 距离同样是 2,分数完全一样
而如果只是把位置向量加 到 x 上(Sinusoidal/Learned),(x_m + p_m)·(x_n + p_n) 展开后有 x_m·p_n、p_m·p_n 等交叉项,混着绝对位置,得不到这么干净的性质。
一句话总结 :旋转保持夹角,所以「各转各的绝对位置」在点积里自动相减成相对距离;多对维度用不同转速,快的分辨近距离、慢的分辨远距离。
类比:像时区。每个人按自己的本地时间记录(绝对位置),但两人对话只关心时差(相对位置)——而时差不管在哪个时区换算都不变。类比不成立之处:角度是周期的,转满一圈会「撞车」,所以 RoPE 要用几十种转速组合来避免歧义;而且训练时没见过的大转角(超长距离),模型未必处理得好——这就是长上下文外推的难点。
于是:
编码时用的是绝对 位置(每个 token 按自己的 m 旋转,实现简单、KV Cache 里存的就是旋转后的 K);
attention 分数里体现的却是相对 距离 n − m;
没有新增参数;每层 attention 内都施加,而不是只在输入处;
只作用于 Q 和 K,V 不旋转 (位置只影响「看谁」,不影响「拿到什么」)。
直觉上:高频的维度对(θ 大)转得快,对近距离敏感;低频的维度对转得极慢,携带长距离信息。base=10000 时,最慢的那组要接近 2π×10000 ≈ 6.3 万个位置才转一圈(head_dim=128 时约 5.4 万)——这也是为什么要扩长上下文时会把 base 调大。
代码片段
HF Transformers 风格的 RoPE(rotate_half 写法):
import torch
def apply_rope(x, positions, base=10000.0):
# x: (B, n_head, T, hs) positions: (T,) 每个 token 的绝对位置
hs = x.shape[-1]
inv_freq = 1.0 / (base ** (torch.arange(0, hs, 2).float() / hs)) # θ_i, (hs/2,)
angles = positions[:, None].float() * inv_freq[None, :] # m·θ_i, (T, hs/2)
emb = torch.cat([angles, angles], dim=-1) # (T, hs)
cos, sin = emb.cos(), emb.sin()
x1, x2 = x[..., : hs // 2], x[..., hs // 2 :]
rotated = torch.cat([-x2, x1], dim=-1) # 把 (x1, x2) 变成 (-x2, x1)
return x * cos + rotated * sin # 2D 旋转: (x1cos - x2sin, x2cos + x1sin)
# 在 attention 里:
# q = apply_rope(q, pos); k = apply_rope(k, pos) # V 不动
# att = q @ k.transpose(-2, -1) / hs**0.5 ...
inv_freq 就是各维度对的旋转频率;base 即 config 里的 rope_theta。
最后一行就是二维旋转矩阵 [[cos, -sin], [sin, cos]] 的逐元素写法,不需要真的构造矩阵。代入 hs=4、x=[a, b, c, d]:配对是 (a, c) 和 (b, d),rotated = [-c, -d, a, b],结果前半 = [a·cos − c·sin, b·cos − d·sin]、后半 = [c·cos + a·sin, d·cos + b·sin]——正是每一对各自旋转。
torch.cat([angles, angles]) 让第 i 维和第 i+hs/2 维用同一个角度,因为它们是一对。
这里把第 i 维和第 i+hs/2 维配成一对;Meta 原始 Llama 代码用复数把相邻的 (2i, 2i+1) 配对。两种布局数学等价,但权重不能混用 ——HF 在转换 Llama 权重时专门对 Q/K 投影做了一次重排。
工程视角 / 真实世界
模型
位置编码
备注
Transformer (2017)
Sinusoidal
加在输入
GPT-2 / GPT-3
Learned absolute
GPT-2 为 1024 位置(nanoGPT 里的 wpe)
BERT
Learned absolute
512 位置
BLOOM、MPT
ALiBi
无位置向量
Llama 1/2
RoPE, θ=10000
Llama 3
RoPE, θ=500000
调大 base 以支持更长上下文
推理系统里的 RoPE :vLLM / SGLang 在写入 KV Cache 之前 对 K 施加 RoPE,缓存里存的是已旋转的 K;decode 时只需按新 token 的位置旋转它的 Q 和 K。cos/sin 表通常预先算好(cos_sin_cache)。
位置 ID 不一定是 0..T-1 :continuous batching 里多个请求拼在一个 batch,每个请求要有自己的 positions;这就是 kernel 接口里单独传 positions 张量的原因。
NoPE :也有研究发现 causal decoder 不加任何位置编码也能从 mask 中推断位置;Meta 的 Llama 4 就在部分层去掉了 RoPE(交错使用)。
💬 能聊的要点
「Attention 是置换不变的,必须注入位置;GPT-2 用可学习的绝对位置向量,只在输入加一次,训练长度之外完全没法外推。」
「RoPE 把 Q/K 每两维当成平面向量按 位置×频率 旋转,点积只依赖角度差,所以用绝对位置实现、得到相对位置效果,且零参数、不碰 V。」
「RoPE 的 base(rope_theta)决定最低频维度的波长,Llama 3 把它从 1 万调到 50 万就是为了让长距离的位置仍可区分;各种长上下文外推方法(PI/NTK/YaRN)本质都在改这些频率。」
常见误区
❌ RoPE 是加在 embedding 上的 ✅ RoPE 在每一层的 attention 里乘在 Q、K 上,是旋转(乘法),不是加法,也不作用于 V。
❌ Sinusoidal 编码因为是公式,所以能无限外推 ✅ 公式能算出任意位置的向量,但模型没在那些位置上训练过,实际外推效果很差。
❌ 位置编码只影响长上下文 ✅ 没有它模型连词序都分不清,短句也会出问题。
自测(心算/口答即可)
nanoGPT 加载 GPT-2 权重后,输入第 1025 个 token 会发生什么?答案 wpe 只有 1024 行,索引越界直接报错;nanoGPT 在 forward 里用 assert t <= block_size 挡住,generate 里则把上下文裁到最近的 block_size 个 token。
RoPE 下,位置 (5, 8) 的一对 q/k 与位置 (105, 108) 的同样一对 q/k,attention 分数一样吗?答案 一样(假设 q、k 向量内容相同):RoPE 的分数只依赖相对距离 8−5 = 108−105 = 3。
为什么 V 不需要旋转?答案 位置信息只需要影响「看谁」(Q·K 的打分),Value 是被取走的内容本身;而且 RoPE 的相对位置性质是靠 q 和 k 的旋转相互抵消得到的,V 不参与点积,旋转它没有这个效果。
延伸
🎬 Karpathy:Let's build GPT 中加入 position_embedding_table 的部分(learned absolute);Let's reproduce GPT-2 (124M) 中对 GPT-2 wpe 权重的可视化
🎬 李沐:zh.d2l.ai 第 10.6 节「自注意力和位置编码」(正弦位置编码);「Transformer 论文逐段精读」中 Positional Encoding 一段
📄 Su et al., RoFormer: Enhanced Transformer with Rotary Position Embedding (2021);Press et al., Train Short, Test Long: Attention with Linear Biases (ALiBi, 2021);EleutherAI 博客 Rotary Embeddings: A Relative Revolution
下一课 → 14 · Residual 与 Normalization:Pre-Norm、LayerNorm → RMSNorm
14 · Residual 与 Normalization:Pre-Norm、LayerNorm → RMSNorm
⏱ 10 分钟 | 前置:13 · Positional Encoding | 难度:★★☆
一句话
Residual(残差)连接给梯度修了一条直达的「高速公路」,Normalization 把每个 token 向量的尺度拉回稳定范围;把 Norm 放在子层之前 (Pre-Norm)并用更便宜的 RMSNorm,是今天几乎所有 LLM 的标配:x = x + f(RMSNorm(x))。
为什么要关心
Attention 和 FFN 负责「算」,但一个 96 层的网络能不能训得动 ,靠的是 Residual 和 Norm。Karpathy 在 Let's build GPT 里演示过:把几个 block 直接堆起来,loss 并没有明显改善(深层网络开始出现优化困难);加上 residual 和 LayerNorm 后才开始随深度改善。工程上,Norm 的位置决定了要不要 warmup、训练会不会发散;RMSNorm 这类「小算子」又是推理 kernel fusion 的常见对象。
核心直觉
1. Residual:默认「什么都不做」
没有 residual: x → f1 → f2 → ... → f96 → out 梯度要穿过 96 个函数的连乘
有 residual: x ──┬──────────────┬──────── ... ──► out
└→ f1 ─(+)─────┘└→ f2 ─(+)
x = x + f(x) 的含义:每个 block 只需学一个增量 (修正量)。如果某层没用,它学到 f ≈ 0 就行,而不是费力学成恒等映射(identity,输出 = 输入)。这个想法来自 ResNet(He et al., 2015),让 152 层的 CNN 成为可能。
🧩 难点拆解:为什么 residual 能救梯度
先直觉 :backprop(第 3 课)里,梯度从 loss 往回传,每穿过一层就要乘上这一层的「局部导数」。很多个小于 1 的数连乘,很快就趋近 0(梯度消失 ),前面的层就收不到学习信号。Residual 在每层旁边开了一条「局部导数恒为 1」的旁路。
小例子 (把每层简化成一个标量函数,局部导数都是 0.5):
没有 residual: y = f(x) dy/dx = 0.5
20 层连乘 → 0.5^20 ≈ 0.000001 第 1 层几乎学不到东西
有 residual: y = x + f(x) dy/dx = 1 + 0.5
把 20 层展开:x_20 = x_0 + f_1 + f_2 + ... + f_20
∂x_20/∂x_0 里始终有一项「1」(直接走 x_0 那条路) 梯度不会消失
加法的导数是 1:反向传播时,加法节点把收到的梯度原样复制 给两个输入分支。所以从 loss 到第 1 层总有一条不经过任何矩阵乘的路径 。
一句话总结 :residual 把「连乘」变成了「连加」,梯度至少能沿着恒等路径原样传回第 1 层。
类比:网络请求经过 96 层代理,每层都可能把消息改坏;residual 相当于在每层旁边保留一份原始消息的直连副本。类比不成立处:直连路径上的数值也会不断累加变大(1+f' 连乘可能变大而非变小),所以还需要下面的 Normalization 和小初始化来控制尺度。
2. Residual Stream:更好的心智模型
Anthropic 的 A Mathematical Framework for Transformer Circuits 提出一个很好用的视角:把中间那条贯穿所有层的 x 看成一条共享总线 (residual stream)。
embedding ═══════╦═══════════╦═══════════╦══════ ... ══► ln_f → lm_head
║ read ║ read ║
[attn]──write [mlp]──write [attn]...
每个 attention / FFN 子层从总线上读 (经过 Norm),算完再写回 (加回去)。所以 d_model 本质是这条总线的「带宽」,各层之间靠它通信。
3. 为什么还要 Normalization
每层都往总线上加东西,x 的数值尺度会随深度漂移;子层的输入尺度一乱,激活和梯度就不稳定。Norm 把每个 token 的向量拉回「均值 0 / 方差 1」附近,再用可学习的 γ(gain,逐维缩放系数,初始为 1)和 β(bias,逐维偏移,初始为 0)恢复表达力——否则所有输入都被强行压成同一个尺度,模型就没法表达「这一维应该大一点」。
为什么是 LayerNorm 而不是 BatchNorm (makemore Part 3 讲过 BatchNorm):
BatchNorm
LayerNorm
统计量沿哪个维度算
batch 维(跨样本)
feature 维(单个 token 自己)
依赖 batch 大小
是,推理要用 running stats
否
适合变长序列 / 逐 token 生成
差
好
LayerNorm 对每个 token 独立计算,训练和推理行为完全一致——这对 decode 时 batch 里只有零星几个 token 的场景至关重要。
4. Post-Norm vs Pre-Norm
Post-Norm (原始 Transformer, BERT): x = LN(x + f(x))
Pre-Norm (GPT-2 起, 几乎所有 LLM): x = x + f(LN(x)) 最后再加一个 final LN
🧩 难点拆解:Pre-Norm vs Post-Norm 差在哪
先直觉 :两者都有 Norm、都有 residual,区别只在 Norm 是否挡在「高速公路」上。
Post-Norm: x ─┬─► f ─►(+)─► LN ─┬─► f ─►(+)─► LN ─► ... 每层的 LN 都在主路上
└────────┘ └────────┘
Pre-Norm: x ─┬─► LN ─► f ─►(+)─┬─► LN ─► f ─►(+)─► ... ─► ln_f
└────────────────┘ └────────────────┘ 主路上只有加法
因果链 :
Pre-Norm 展开后 x_L = x_0 + f_1(LN(x_0)) + f_2(LN(x_1)) + ...,和上面的 🧩 一样,主路是纯加法,梯度直达。
Post-Norm 的主路每层都要穿过一次 LN。LN 会除以当前向量的标准差(例如 stream 的尺度是 5,就除以 5),梯度回传时也被相应缩放;叠几十层,各层收到的梯度大小就很不均匀。
Xiong et al. (2020) 分析发现,Post-Norm 在初始化时靠近输出层的梯度偏大,必须配合 learning rate warmup(训练开头几千步把学习率从很小慢慢升到目标值)才能稳定训练。
Pre-Norm 深层训练稳定得多,对 warmup 不那么敏感。代价是 residual stream 的尺度会随层数增长(一直在加、从不归一化),所以要在最后加一个 ln_f;也有观点认为 Pre-Norm 深层的贡献会被稀释(每层加的一小份相对总量越来越小)。
一句话总结 :Post-Norm 在主路上归一化(每层把梯度也「改写」一次),Pre-Norm 只在支路入口归一化(主路保持纯加法),所以深层 LLM 都选 Pre-Norm + final norm。
GPT-2 论文明确写了这两个改动:LN 挪到每个子块的输入处,并在最后一个 block 后加一层 LN。
5. LayerNorm → RMSNorm
LayerNorm: y = (x - mean(x)) / sqrt(var(x) + eps) * γ + β
RMSNorm: y = x / sqrt(mean(x²) + eps) * γ
eps 是一个很小的数(如 1e-5、1e-6),防止除以 0。RMS = Root Mean Square(均方根)。
代入 x = [1, 2, 3, −2](γ=1、β=0,忽略 eps):
LayerNorm: mean = 1,x − mean = [0, 1, 2, −3],var = (0+1+4+9)/4 = 3.5,std ≈ 1.87
y ≈ [0, 0.53, 1.07, −1.60] 均值 0、方差 1
RMSNorm: mean(x²) = (1+4+9+4)/4 = 4.5,rms ≈ 2.12
y ≈ [0.47, 0.94, 1.41, −0.94] 不居中,只把「整体大小」缩到 1
RMSNorm(Zhang & Sennrich, 2019)去掉了「减均值」和 β,只做尺度归一化。实践表明效果基本不掉,但少一次归约(reduction,对整个向量求和/求均值这类「多变一」的操作,在 GPU 上需要线程间同步),少一组参数,更快也更省。Llama 系列、Qwen、DeepSeek、Mistral 等基本都用 RMSNorm。
代码片段
import torch, torch.nn as nn
class RMSNorm(nn.Module):
def __init__(self, dim, eps=1e-6):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim)) # γ,没有 β
def forward(self, x): # x: (B, T, C)
rms = x.pow(2).mean(-1, keepdim=True) # 每个 token 单独算
return x * torch.rsqrt(rms + self.eps) * self.weight
class Block(nn.Module): # Pre-Norm Transformer block
def __init__(self, dim, attn, mlp):
super().__init__()
self.norm1, self.attn = RMSNorm(dim), attn
self.norm2, self.mlp = RMSNorm(dim), mlp
def forward(self, x):
x = x + self.attn(self.norm1(x)) # 读总线 → 算 → 写回
x = x + self.mlp(self.norm2(x)) # residual 路径上没有任何 Norm
return x
mean(-1):沿 feature 维求均值,这就是「Layer」Norm 的含义——跨特征、不跨样本。keepdim=True 让结果保持 (B, T, 1),才能广播回去和 (B, T, C) 相乘。
torch.rsqrt = 1/√x,一个算子完成「开方再取倒数」,乘法比除法便宜。
Block.forward 两行就是 nanoGPT / Llama block 的全部骨架;nanoGPT 用 LayerNorm,Llama 用 RMSNorm,结构相同。
实际实现中常把 RMSNorm 的计算提升到 fp32 再转回 bf16,避免平方求和时精度不够(bf16 只有 2–3 位有效十进制数字;若用 fp16 还可能溢出)。
工程视角 / 真实世界
初始化配合 residual :GPT-2 把写回 residual 的投影层权重按 1/√N 缩小(N 为 residual 层数;N 个独立随机量相加,方差变 N 倍、标准差变 √N 倍,所以每份先缩小 √N),nanoGPT 里对应 c_proj.weight 用 std=0.02/math.sqrt(2 * n_layer)——每个 block 往总线写两次(attn + mlp),所以是 2×n_layer。目的是防止总线方差随深度累积变大。
Kernel fusion :推理框架常把「residual add + RMSNorm」融合成一个 kernel(vLLM 的 fused_add_rms_norm),一次读写完成两步,省一趟显存往返——对 decode 这种带宽瓶颈阶段很划算。
Norm 仍在演化 :Gemma 2 在子层前后都加 RMSNorm;QK-Norm(对 Q、K 再做一次 Norm,第 25 课)被一些新模型用来稳定大规模训练。
💬 能聊的要点
「Residual 让每层只学增量,并给梯度一条不经过矩阵乘的直达路径;把 x 看成各层读写的 residual stream,d_model 就是这条总线的带宽。」
「Pre-Norm 把 Norm 放进分支里,residual 路径保持纯加法,深层训练稳定、对 warmup 不那么敏感;代价是 stream 尺度随深度增长,需要一个 final norm。GPT-2 之后的 LLM 基本都是 Pre-Norm。」
「RMSNorm 去掉减均值和 bias,只按均方根缩放,效果几乎不掉但更便宜;推理框架常把 residual add 和 RMSNorm 融合成一个 kernel 来省显存带宽。」
常见误区
❌ LayerNorm 和 BatchNorm 只是名字不同 ✅ 归一化的维度不同:LN 对单个 token 的特征做,BN 对一个 batch 里的同一特征做;LN 与 batch 大小无关,适合变长序列和逐 token 生成。
❌ Pre-Norm 就是把 Norm 挪到前面,没有别的区别 ✅ 关键是 residual 主路径上不再有 Norm;因此必须在所有 block 之后加 final norm,否则 lm_head 看到的是未归一化的 stream。
❌ Residual 只是为了解决梯度消失 ✅ 它也改变了表示方式:各层以「加法写入共享总线」协作,这是理解 Transformer 内部机制的基础视角。
自测(心算/口答即可)
d_model=4096 的一个 RMSNorm 有多少参数?同维度 LayerNorm 呢?答案 RMSNorm 只有 γ:4096 个;LayerNorm 有 γ 和 β:8192 个。
为什么 decode 时用 BatchNorm 会出问题?答案 BatchNorm 的统计量依赖 batch 内其他样本,推理时只能用训练期的 running statistics;而 LN/RMSNorm 对每个 token 独立计算,训练和推理行为一致,与 batch 里有哪些请求无关。
一个 12 层的 GPT-2 small,nanoGPT 对 c_proj 初始化的 std 是多少?答案 0.02 / √24 ≈ 0.0041。
延伸
🎬 Karpathy:Let's build GPT 中「residual connections」和「layernorm (and its relationship to our previous batchnorm)」两节;Building makemore Part 3: Activations & Gradients, BatchNorm
🎬 李沐:zh.d2l.ai 第 7.5 节「批量规范化」、第 7.6 节「残差网络(ResNet)」、第 10.7 节「Transformer」(残差连接和层规范化);「ResNet 论文逐段精读」
📄 He et al., Deep Residual Learning for Image Recognition (2015);Xiong et al., On Layer Normalization in the Transformer Architecture (2020);Zhang & Sennrich, Root Mean Square Layer Normalization (2019)
下一课 → 15 · FFN:从 GELU 到 SwiGLU,参数都藏在这里
15 · FFN:从 GELU 到 SwiGLU,参数都藏在这里
⏱ 10 分钟 | 前置:14 · Residual 与 Normalization | 难度:★★☆
一句话
FFN(Feed-Forward Network,也叫 MLP)是每个 token 各自 过的一个「升维 → 非线性 → 降维」两层网络;它不在 token 之间交流信息,却占了一个 block 约 2/3 甚至更多的参数,现代 LLM 用带门控的 SwiGLU 取代了 ReLU/GELU。
为什么要关心
如果说 Attention 是「开会交流」,FFN 就是「各自回工位思考」。大部分参数和训练/推理 FLOPs 都花在这里——MoE(第 21–22 课)本质上就是「把 FFN 换成一堆可选的 FFN」。看 HF config 时的 intermediate_size 就是它;看到 gate_proj / up_proj / down_proj 三个矩阵,就知道这是 SwiGLU。
核心直觉
1. Attention 负责通信,FFN 负责计算
Attention: token_i ← 从其他 token 收集信息 (跨 token,mix sequence)
FFN: token_i → 独立加工自己的向量 (逐 token,mix features)
Karpathy 在视频里的说法:token 们在 attention 里「互相看过」之后,需要时间「想一想」刚收集到的信息。FFN 是 position-wise 的:同一套权重对每个位置独立应用,相当于把 (B, T, C) 展平成 (B·T, C) 再做一次大矩阵乘(GEMM)——所以它对 batch 和序列长度都「无感」(token 多了只是矩阵行数变多),非常适合 GPU 高效计算。
2. 结构:先放大 4 倍,再压回来
x (C) ──W1──► h (4C) ──activation──► (4C) ──W2──► out (C)
up down
原始 Transformer:d_model=512,d_ff=2048(4 倍)。GPT-2 同样 4 倍:768 → 3072 → 768。
为什么要升维?非线性需要一个足够宽的中间层才能表达复杂函数;在高维里用激活函数「挑选」出有用的特征组合,再投影回 residual stream。
一个有用的解读 :Geva et al. (2021) 把 FFN 看成 key-value memory——W1 的每一行是一个「模式探测器」(key),激活越大说明输入越匹配;W2 对应的列是该模式要写回的内容(value)。后续的知识编辑研究(如 ROME:直接改某层 MLP 权重来修改模型记住的某条事实)也把事实性知识定位到中间层 MLP。所以常说「知识主要存在 FFN 里」(这是研究观点,不是定论)。
3. 参数都藏在这里
MHA 的一个 block(忽略 bias、Norm):
Attention: W_Q, W_K, W_V, W_O = 4 · d²
FFN: W1 (d×4d) + W2 (4d×d) = 8 · d² ← 占 2/3
如果再用 GQA(Grouped-Query Attention:多个 Query head 共享一组 K/V,第 19 课)把 K/V 投影缩小,FFN 占比更高。例如 Llama-3-8B(d=4096,32 个 Q head、8 个 KV head,head_dim=128,intermediate_size=14336):
Attention: W_Q, W_O 各 4096×4096 ≈ 16.8M;W_K, W_V 各 4096×(8×128=1024) ≈ 4.2M → 合计 ≈ 42M
FFN: 3 × 4096 × 14336 ≈ 176M
FFN 占比: 176 / (176 + 42) ≈ 81%
4. 激活函数的演进:ReLU → GELU → SwiGLU
ReLU(x) = max(0, x) 原始 Transformer;x<0 处梯度为 0(dead neuron)
GELU(x) = x · Φ(x) GPT-1/2/3、BERT;平滑版 ReLU,负半轴有小的非零值
SiLU(x) = x · sigmoid(x) 也叫 Swish,形状与 GELU 很接近
SwiGLU(x)= (SiLU(x·W_gate) ⊙ (x·W_up)) · W_down Llama、Qwen、DeepSeek、Mistral…
符号:Φ 是标准正态分布的累积分布函数(输入越大越接近 1);sigmoid(x) = 1/(1+e^(−x)),把任意数压到 (0,1);⊙ 表示逐元素相乘。dead neuron = 某个中间维度的输入总是负数,ReLU 输出恒为 0、梯度也恒为 0,这个维度就永远学不动了。
GLU(Gated Linear Unit)的关键在「门控」:用两路投影,一路过激活当作 门 (gate),逐元素乘到另一路(up)上。相比「激活后直接用」,门控让网络可以按输入动态地放大或关掉每个中间维度,表达力更强。
🧩 难点拆解:SwiGLU 的「门」在做什么
先直觉 :普通 FFN 里,一个中间维度「开多大」和「值是多少」由同一个数决定(GELU(h));SwiGLU 把它拆成两路:gate 路决定开关大小,up 路决定内容。类比:feature flag × 业务数据——flag 由一套逻辑算出来,数据由另一套逻辑算出来,最后相乘。
小例子 (中间维度只有 3 维,数字为示意):
gate_proj(x) = [ 3.0, 0.0, -3.0]
SiLU(gate) ≈ [ 2.86, 0.00, -0.14] SiLU(3)=3×0.95,SiLU(-3)=-3×0.05
up_proj(x) = [ 1.0, 5.0, 2.0]
相乘 ≈ [ 2.86, 0.00, -0.28] 第 2 维 up 值是 5,但门关着 → 0
down_proj → 压回 d 维
第 2 维的 up 值再大,门是 0 就被完全关掉;第 1 维门开得很大,内容被放大。「开多少」和「放什么」由两套独立的权重学习。
一句话总结 :SwiGLU = 用 SiLU(gate) 当逐维的软开关,乘在 up 的内容上;代价是多了一个矩阵,所以中间维度要缩小(见第 5 节)。
类比不成立处:这里的「开关」不是 0/1,而是连续值,还可以是小负数。
Shazeer (2020) 的 GLU Variants Improve Transformer 系统比较了多种变体,SwiGLU/GeGLU 在同等计算下效果最好;论文结尾那句著名的话是:他们无法解释为什么有效,「归功于神的仁慈(divine benevolence)」。
5. 为什么 intermediate_size 是 8/3·d 左右
SwiGLU 有三个矩阵(gate、up、down),如果中间维度还用 4d,参数就是 12d² 而不是 8d²。为了与普通 FFN 参数量持平 ,把中间维度缩到 2/3 × 4d = 8d/3,三个矩阵合计 3 × d × 8d/3 = 8d²。
走一遍数字(d=4096):
普通 FFN:2 个矩阵 × 4096 × 16384 = 8d² ≈ 134M。
SwiGLU 要保持 134M,3 个矩阵平分:每个 ≈ 44.7M,中间维度 = 44.7M / 4096 ≈ 10923(= 8/3 × 4096)。
Llama-2-7B 代码里先取整数部分得 10922,再向上取整到 256 的倍数(对 GPU 更友好)→ 11008 。
Llama-3-8B 则在 Meta 代码里额外乘了 ffn_dim_multiplier=1.3、再取整到 1024 的倍数:10922 × 1.3 ≈ 14199 → 14336 (=3.5×4096),即故意把 FFN 做得更宽。
代码片段
import torch.nn as nn, torch.nn.functional as F
class MLP(nn.Module): # GPT-2 / nanoGPT 风格
def __init__(self, d):
super().__init__()
self.c_fc = nn.Linear(d, 4 * d)
self.c_proj = nn.Linear(4 * d, d)
def forward(self, x): # x: (B, T, d)
return self.c_proj(F.gelu(self.c_fc(x))) # (B,T,4d) -> (B,T,d)
class SwiGLU(nn.Module): # Llama 风格
def __init__(self, d, hidden): # hidden 如 11008
super().__init__()
self.gate_proj = nn.Linear(d, hidden, bias=False)
self.up_proj = nn.Linear(d, hidden, bias=False)
self.down_proj = nn.Linear(hidden, d, bias=False)
def forward(self, x):
gate = F.silu(self.gate_proj(x)) # 门:决定每个维度开多少
return self.down_proj(gate * self.up_proj(x)) # 逐元素相乘后降维
两个类的输入输出 shape 完全一样,可以直接替换进 block——这就是为什么架构升级常常只改这一小块。
SwiGLU 的 gate_proj 和 up_proj 输入相同,推理框架通常把它们合并成一个 gate_up_proj(输出宽度 2×hidden)做一次 GEMM,再用融合 kernel 算 silu(a) * b(vLLM 里叫 SiluAndMul)。
现代 LLM 普遍去掉 Linear 的 bias(bias=False),省参数且训练更稳定;GPT-2 还带 bias。
工程视角 / 真实世界
模型
d_model
FFN 类型
intermediate_size
Transformer base
512
ReLU
2048
GPT-2 small
768
GELU
3072
Llama-2-7B
4096
SwiGLU
11008
Llama-3-8B
4096
SwiGLU
14336
Tensor Parallel :FFN 是 TP 的经典切法——W1(及 gate/up)按列切、W2 按行切,中间的激活函数逐元素、无需通信,整个 FFN 只在最后做一次 all-reduce(Megatron-LM 论文的核心设计之一,第 30 课详讲)。
MoE 的切入点 :MoE 把一个大 FFN 换成 N 个小 FFN(expert)+ 一个 Router,每个 token 只激活其中 k 个。因为 FFN 本来就是 position-wise 的,按 token 路由到不同 expert 是很自然的扩展。
激活稀疏 :ReLU 类激活后很多中间值为 0,有工作利用这一点加速推理;SwiGLU 下的稀疏性较弱,这类技巧收益也随之变化。
💬 能聊的要点
「Attention 负责 token 间通信,FFN 负责逐 token 的特征变换;FFN 是 position-wise 的,本质是一个大 GEMM,在标准 MHA block 里占约 2/3 参数,用了 GQA 后占比更高。」
「SwiGLU 用一路 SiLU 激活作为门、逐元素乘到另一路上,比 GELU 效果更好;为了参数量持平,中间维度从 4d 缩到约 8d/3,所以 Llama-2-7B 是 11008 而不是 16384。」
「FFN 是 TP 和 MoE 的天然切入点:列切 + 行切只需一次 all-reduce;把它换成多个 expert 加路由就是 MoE。」
常见误区
❌ 参数主要在 Attention 里 ✅ 标准 block 里 FFN 占 2/3,现代 GQA 模型里常超过 3/4;(小模型里 embedding 也占很大比例,见下一课)。
❌ SwiGLU 就是换了个激活函数 ✅ 它改变了结构:多了一个门控投影矩阵(三个矩阵而非两个),因此中间维度也要相应调整。
❌ FFN 的「4 倍」是必须的 ✅ 只是经验值;Llama-3-8B 的 SwiGLU 中间维度是 3.5×d,各家会根据算力和硬件对齐调整。
自测(心算/口答即可)
GPT-2 small 一层 MLP(含 bias)有多少参数?答案 c_fc: 768×3072 + 3072 = 2,362,368;c_proj: 3072×768 + 768 = 2,360,064;合计 4,722,432 ≈ 4.7M。
d=4096,若 SwiGLU 的中间维度仍用 4d=16384,一层 FFN 参数比 8d² 多多少?答案 3 × 4096 × 16384 = 12d² ≈ 201M,比 8d² ≈ 134M 多 50%。
为什么说 FFN 对序列长度「无感」?答案 它对每个 token 独立计算,计算量与 token 数线性相关,不存在 attention 那样的 T² 项;实现上把 (B,T) 展平成一个维度做矩阵乘即可。
延伸
🎬 Karpathy:Let's build GPT 中「feedforward layers of transformer block」一节;Let's reproduce GPT-2 (124M) 中关于 GELU(tanh 近似)的讨论
🎬 李沐:zh.d2l.ai 第 10.7 节「Transformer」(基于位置的前馈网络);「Transformer 论文逐段精读」
📄 Shazeer, GLU Variants Improve Transformer (2020);Geva et al., Transformer Feed-Forward Layers Are Key-Value Memories (2021);Hendrycks & Gimpel, Gaussian Error Linear Units (GELUs) (2016)
下一课 → 16 · GPT 全貌:nanoGPT 代码走读 + 参数量怎么算
16 · GPT 全貌:nanoGPT 代码走读 + 参数量怎么算
⏱ 10 分钟 | 前置:15 · FFN:从 GELU 到 SwiGLU | 难度:★★☆
一句话
GPT = Token/Position Embedding + N 个相同的 Pre-Norm Block(Attention + MLP,各带 residual)+ Final Norm + LM Head;GPT-2 124M 的参数可以一行行数出来,粗算公式是 每层 ≈ 12·d² 。
为什么要关心
前面 6 课拆开了零件,这一课把它们装回一台完整的机器。能对着代码说出每个张量的 shape、能心算一个模型有多少参数、多少显存、多少 FLOPs,是看懂任何模型 config、估算训练/推理成本、和别人讨论架构的基本功。nanoGPT 的 model.py 约 300 行,是最适合通读的 GPT 实现。
核心直觉
1. 整体结构(nanoGPT GPT 类)
idx (B,T) token ids
│
├─ wte: Embedding(50257, 768) ─┐
├─ wpe: Embedding(1024, 768) ─┴─ 相加 → dropout
▼
┌─────────────── Block × 12 ────────────────┐
│ x = x + attn(ln_1(x)) CausalSelfAttention│
│ x = x + mlp(ln_2(x)) Linear-GELU-Linear│
└────────────────────────────────────────────┘
▼
ln_f → lm_head: Linear(768, 50257, bias=False) → logits (B,T,50257)
▲
└── 与 wte 共享同一个权重矩阵(weight tying)
GPT-2 124M 的配置:n_layer=12, n_head=12, n_embd=768, block_size=1024, vocab_size=50257。
2. 参数量逐项数(GPT-2 124M,精确值)
设 d=768,V=50257,T_max=1024,L=12。
组件
形状
参数量
wte(token embedding)
50257 × 768
38,597,376
wpe(position embedding)
1024 × 768
786,432
每个 Block:
ln_1(γ, β)
2 × 768
1,536
attn.c_attn(QKV)
768×2304 + 2304
1,771,776
attn.c_proj(W_O)
768×768 + 768
590,592
ln_2
2 × 768
1,536
mlp.c_fc
768×3072 + 3072
2,362,368
mlp.c_proj
3072×768 + 768
2,360,064
小计 / Block
7,087,872
12 个 Block
×12
85,054,464
ln_f
2 × 768
1,536
lm_head
与 wte 共享
0
总计
124,439,808 ≈ 124M
🧩 难点拆解:12·L·d² 是怎么来的
先直觉 :一个 Block 里几乎所有参数都在「d 维进、d 维出」的矩阵里;把这些矩阵的「宽 × 高」都写成 d 的倍数,加起来就是 12d²。
一步步数 (忽略 bias 和 LN):
Attention:W_Q、W_K、W_V 各 d×d(多头只是切分,不加参数),W_O 也是 d×d → 4d² 。
MLP:c_fc 是 d×4d,c_proj 是 4d×d → 4d² + 4d² = 8d² 。
一个 Block = 4d² + 8d² = 12d² ;L 个 Block = 12·L·d² 。
代入 GPT-2 small (d=768):
d² = 589,824
12d² = 7,077,888 ← 粗算
精确 / Block = 7,087,872 ← 上表
差 9,984 = bias(2304 + 768 + 3072 + 768) + LN(1536 × 2)
粗算误差只有 0.14%。模型越大,d² 相对 d 越大,bias/LN 越可忽略。
一句话总结 :非 embedding 参数 ≈ 12·L·d²(4d² attention + 8d² MLP);embedding 另算 V·d。注意这是 GPT-2 式 MHA + 4 倍 GELU MLP 的公式,GQA/SwiGLU 模型要按上一课的方法重算。
几个观察:
粗算公式好用 :12 × 12 × 768² ≈ 84.9M,与精确的 85.05M 很接近。GPT-3(L=96, d=12288)→ 12×96×12288² ≈ 174B,与官方 175B 吻合。
小模型里 embedding 占大头 :wte 38.6M 占总量约 31%。如果不做 weight tying,lm_head 还要再来 38.6M。
nanoGPT 打印的是 123.65M :get_num_params() 默认减掉了 wpe(non-embedding 统计;wte 因为和 lm_head 共享,算作输出层保留)。
3. Weight Tying:输入输出共享一个矩阵
wte 把 token id 映射成向量(查第 id 行),lm_head 把向量映射回每个 token 的分数(和每一行做点积)——两者是互逆的操作,共享一个 V × d 矩阵既省参数(省下 38.6M:不共享时总量约 163M,共享后 124M,少了约 24%),实验上效果也不差(Press & Wolf, 2017)。GPT-2 就这么做;很多更大的现代模型(如 Llama-2-7B)则不共享,因为 embedding 占比已经很小。
4. 显存与算力的粗算
权重:124M × 4 字节(fp32)≈ 0.5 GB;bf16 约 0.25 GB。
训练 FLOPs ≈ 6 × N × D (N = 参数量,D = 训练 token 数)。为什么是 6:
每个参数在前向里对每个 token 做一次乘法 + 一次加法 = 2 FLOPs → 前向 2N / token;
反向要算两样东西:对输入的梯度(传给前一层)和对权重的梯度(用来更新),各约 2N → 反向 4N / token;
合计 6N / token,乘以 D 个 token。
推理每 token 前向约 2N FLOPs(未计 attention 的 T² 项)。nanoGPT 的 estimate_mfu()(MFU = Model FLOPs Utilization,实际算力占 GPU 峰值的比例)用的就是这个公式外加 attention 项,这个 6ND 会在第 18 课 Scaling Laws 里反复出现。
代码片段
nanoGPT GPT.forward(略删减),每行标注 shape(B=批大小,T=序列长度):
def forward(self, idx, targets=None):
B, T = idx.size() # idx: (B, T) int64
pos = torch.arange(0, T, device=idx.device) # (T,)
tok_emb = self.transformer.wte(idx) # (B, T, 768)
pos_emb = self.transformer.wpe(pos) # (T, 768),广播相加
x = self.transformer.drop(tok_emb + pos_emb) # (B, T, 768)
for block in self.transformer.h: # 12 个 Block
x = block(x) # (B, T, 768) 形状不变
# 内部: c_attn → (B,T,2304) → q,k,v 各 (B,12,T,64)
# att (B,12,T,T) → y (B,12,T,64) → (B,T,768) → c_proj
# c_fc → (B,T,3072) → GELU → c_proj → (B,T,768)
x = self.transformer.ln_f(x) # (B, T, 768)
if targets is not None: # 训练
logits = self.lm_head(x) # (B, T, 50257)
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), # (B*T, V)
targets.view(-1), ignore_index=-1) # (B*T,)
else: # 推理:只要最后一个位置
logits = self.lm_head(x[:, [-1], :]) # (B, 1, 50257)
loss = None
return logits, loss
整个 Block 堆栈里 shape 恒为 (B,T,768) ——这就是 residual stream 的宽度,也是 Block 可以任意堆叠的原因。
logits(每个 token 在词表上的未归一化分数,第 8 课)是 (B,T,V);F.cross_entropy 要求输入是「N 个样本 × V 类」,所以用 view(-1, V) 把 B×T 个位置摊平成 B·T 个分类样本,targets 同样摊平成 (B·T,);ignore_index=-1 表示标签为 -1 的位置不计 loss。
训练时每个位置都算 logits,一次前向得到 B×T 个 next-token 预测的 loss;推理时只取最后一个位置,省掉 (B,T,V) 这个最大的张量(T=1024 时 fp32 约 200MB/样本)。
最大的单个中间张量要么是 logits (B,T,V),要么是 attention 分数 (B,H,T,T)——显存优化常从这两处下手。
参数量验证(在 Python 里一行搞定):
model = GPT.from_pretrained('gpt2')
print(sum(p.numel() for p in model.parameters())) # 124439808(共享权重只算一次)
工程视角 / 真实世界
nanoGPT model.py 结构 :LayerNorm(可选 bias)→ CausalSelfAttention → MLP → Block → GPTConfig → GPT(_init_weights、forward、from_pretrained、configure_optimizers、estimate_mfu、generate)。
from_pretrained 的坑 :OpenAI 原始权重用的是 Conv1D,与 nn.Linear 的权重是转置关系,nanoGPT 加载 HF 权重时对 c_attn/c_proj/c_fc 做了 .t()。
vocab 填充 :GPTConfig 默认 vocab_size=50304(50257 向上取到 64 的倍数),多出的 47 个 token 永远不会出现,但让矩阵维度更对齐 GPU,训练更快。
configure_optimizers :只对 2D 参数(矩阵乘权重、embedding)做 weight decay,bias 和 LayerNorm 不做。
复现成本 :nanoGPT README 给出在 OpenWebText 上用单机 8×A100 40GB 约 4 天训到 val loss ≈ 2.85;Karpathy 后来在 llm.c 中用优化实现把 GPT-2 124M 的复现压到约 90 分钟、约 20 美元(8×A100 80GB 节点)。
现代模型的差异 :把这台机器换成 Llama,只需替换零件——LayerNorm→RMSNorm、wpe→RoPE、GELU MLP→SwiGLU、MHA→GQA、去掉 bias、不 tie embedding。整体骨架(embedding → N×Pre-Norm Block → norm → lm_head)完全相同。
💬 能聊的要点
「Decoder-only 的 GPT 就是 embedding、N 个 Pre-Norm Block、final norm 和 lm_head;Block 内外 shape 恒为 (B,T,d),这也是 residual stream 的宽度。」
「非 embedding 参数 ≈ 12·L·d²:attention 4d²、MLP 8d²。GPT-2 small 算出来 85M 加上 38.6M 的 embedding 就是 124M;GPT-3 用这个公式能算到约 174B。」
「训练 FLOPs ≈ 6ND、推理每 token ≈ 2N,是估算训练预算和推理成本最常用的两个公式;小模型里 embedding 占比很高,所以 GPT-2 用 weight tying 省了 38.6M 参数(约为不共享时总量的 1/4)。」
常见误区
❌ 124M 里大部分是 Transformer 层 ✅ 约 31% 是 token embedding;越小的模型 embedding 占比越大,大模型里则可忽略。
❌ lm_head 是一个独立的 38.6M 参数的层 ✅ GPT-2 里它和 wte 是同一个张量(weight tying),不额外占参数。
❌ 推理时也要算整个序列的 logits ✅ 生成时只需要最后一个位置;nanoGPT 在 targets=None 时只对 x[:, [-1], :] 过 lm_head。
自测(心算/口答即可)
GPT-2 medium:L=24、d=1024,用 12·L·d² 粗算非 embedding 参数量是多少?答案 12 × 24 × 1024² ≈ 302M;再加 embedding(50257×1024 ≈ 51M,外加 wpe 1024×1024 ≈ 1M)≈ 355M,与 GPT-2 medium 的 350M 量级一致。
用 10B tokens 训练 GPT-2 124M,总训练 FLOPs 约多少?答案 6 × 124M × 10B ≈ 7.4 × 10¹⁸ FLOPs。
训练时 B=8、T=1024,logits 张量在 fp32 下多大?答案 8 × 1024 × 50257 × 4 字节 ≈ 1.65 GB——比模型权重本身(0.5GB)还大,这就是很多框架做「分块计算 cross-entropy」的原因。
延伸
🎬 Karpathy:Let's build GPT: from scratch, in code, spelled out. (全片,最后一段扩展到约 10M 参数的 Shakespeare 模型);Let's reproduce GPT-2 (124M) (从加载 HF 权重、参数量、weight tying 到完整训练)
🎬 李沐:「GPT,GPT-2,GPT-3 论文精读」;zh.d2l.ai 第 10.7 节「Transformer」
📄 nanoGPT model.py ;Radford et al., Language Models are Unsupervised Multitask Learners (GPT-2, 2019);Press & Wolf, Using the Output Embedding to Improve Language Models (2017)
下一课 → 17 · 解码策略:greedy、top-k、top-p、temperature、repetition penalty
17 · 解码策略:greedy、top-k、top-p、temperature、repetition penalty
⏱ 10 分钟 | 前置:16 · GPT 全貌:nanoGPT 代码走读 | 难度:★☆☆
一句话
模型每一步只输出一个 vocab 大小的 logits 向量(每个候选 token 一个未归一化的分数,第 8 课),「选哪个 token」完全由解码策略决定:temperature 调整分布的尖锐程度,top-k / top-p / min-p 截掉长尾,penalty 抑制重复,最后再按概率采样(或直接取 argmax)。
为什么要关心
同一个模型,换一组采样参数,输出可以从「死循环复读」变成「胡言乱语」。你在 API 和推理框架里天天见到的 temperature、top_p、top_k、repetition_penalty,都是本课的内容。对 AI Infra 来说,sampling 还是 decode 每一步都要跑的一段 GPU 代码,它和 speculative decoding、structured output 都直接相关。
核心直觉
1. 生成是一个循环
prompt ──► model ──► logits (V,) ──► [解码策略] ──► next token ──┐
▲ │
└──────────────────── append 到序列末尾 ◄────────────────────┘
模型本身是确定的函数;随机性和「风格」全部来自中间那个框。nanoGPT 的 generate() 就是这个循环的最简实现。
2. Greedy 与 Beam Search:确定性的选择
Greedy :每步取 argmax(分数最高的那个 token)。简单、可复现,但容易陷入重复循环(「I don't know. I don't know. I don't know...」),而且每步局部最优不等于整句最优。
Beam Search :同时保留 k 条得分(整条序列各步概率的乘积,实现上用 log 概率相加)最高的候选序列,每步把每条都往下扩展一个 token,再从所有扩展里留下最好的 k 条。在机器翻译、语音识别这类「有标准答案」的任务里好用;但在开放式生成里,Holtzman et al. 发现高概率文本反而乏味且重复——人写的文本并不总是挑最可能的词。
3. Temperature:调分布的「尖锐度」
p_i = softmax(logits_i / T)(第 8 课讲过;这里的 T 是 temperature,不是序列长度)。除以 T < 1 等于把分数之间的差距放大,softmax 后更尖;T > 1 缩小差距,更平。
logits: [3.0, 2.0, 1.0]
T=1.0 → p ≈ [0.67, 0.24, 0.09]
T=0.5 → p ≈ [0.87, 0.12, 0.02] 更尖,接近 greedy
T=2.0 → p ≈ [0.51, 0.31, 0.19] 更平,更「有创意」也更容易出错
T→0 等价于 greedy(多数推理框架把 temperature=0 直接当作 greedy 处理)。Temperature 不改变排序 ,只改变差距。
4. 截断长尾:top-k、top-p、min-p
问题:vocab 有几万到十几万个 token,即使每个垃圾 token 概率只有 0.001%,加起来也不小,一旦采中就「跑偏」,而且错误会被后续生成放大。解决办法是只在「靠谱的候选」里采样。
top-k (Fan et al., 2018):只保留概率最高的 k 个。缺点是 k 固定——分布很尖时(下一个词几乎确定)k=50 也放进了很多垃圾;分布很平时(很多词都合理)k=50 又可能太少。
top-p / nucleus sampling (Holtzman et al., 2019):按概率从高到低累加,保留累积概率刚好 ≥ p 的最小集合。候选数量随分布自适应 :尖分布时可能只剩 1–2 个,平分布时可以有几百个。
min-p (Nguyen et al., 2024):只保留概率 ≥ min_p × 最大概率 的 token。阈值随模型「自信程度」缩放,在高 temperature 下比 top-p 更稳,近来被 vLLM、llama.cpp 等广泛支持。
分布尖: [0.90, 0.05, 0.02, 0.01, ...] top-p=0.9 → 只留 1 个
分布平: [0.10, 0.09, 0.09, 0.08, ...] top-p=0.9 → 留很多个
🧩 难点拆解:top-p 怎么「自适应」
先直觉 :top-k 规定「留几个」,top-p 规定「留多少概率质量」。候选个数是结果,不是参数——模型越确定,凑够 p 需要的 token 越少。类比负载均衡里「按权重覆盖 90% 流量的最少节点集」,而不是「固定取前 5 台」。
小例子 (top-p=0.9,5 个 token):
情形 A(模型很确定):
排序后概率 [0.92, 0.04, 0.02, 0.01, 0.01]
累积 [0.92, ...] 第 1 个就 ≥ 0.9 → 只留 1 个,等于 greedy
情形 B(模型不确定):
排序后概率 [0.35, 0.25, 0.20, 0.12, 0.08]
累积 [0.35, 0.60, 0.80, 0.92, 1.00]
↑ 第 4 个首次 ≥ 0.9 → 留 4 个
重新归一化 [0.35, 0.25, 0.20, 0.12] / 0.92 ≈ [0.38, 0.27, 0.22, 0.13]
同样的 top_k=2 放到情形 A 会多留一个 0.04 的候选,放到情形 B 会砍掉 40% 概率质量的合理候选;top-p 两种情况都处理得合理。
一句话总结 :top-p = 按概率从高到低排,留下「刚好凑够 p」的最少 token,截掉的是尾部,候选数随模型自信程度自动伸缩。
5. 重复惩罚:penalty 家族
repetition_penalty (来自 CTRL,Keskar et al., 2019):对已经出现过的 token,logit 为正则除以 θ、为负则乘以 θ(θ>1),降低它再次被选中的概率。CTRL 论文建议 θ≈1.2。它是乘法式的,只看「出现过没有」。
presence_penalty / frequency_penalty (OpenAI API 风格):logit -= presence × [出现过] + frequency × 出现次数。presence 鼓励换话题,frequency 随次数线性加重,取值范围 -2.0 到 2.0。
这些惩罚可能误伤:代码、JSON、表格里本来就需要大量重复的 token(括号、缩进、字段名),惩罚过重会让格式出错。
6. 常见的处理流水线
logits → penalties → / temperature → top-k → top-p / min-p → softmax → multinomial 采样
各框架的顺序细节略有差异,但大体如此。
代码片段
在 nanoGPT generate() 的基础上加上 top-p:
@torch.no_grad()
def sample_next(logits, temperature=1.0, top_k=None, top_p=None):
# logits: (B, V) —— 模型对最后一个位置的输出
if temperature == 0:
return logits.argmax(dim=-1, keepdim=True) # greedy
logits = logits / temperature
if top_k is not None: # nanoGPT 原有逻辑
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = float('-inf') # 第 k 名之后全屏蔽
if top_p is not None:
sorted_logits, idx = torch.sort(logits, descending=True)
cum = torch.softmax(sorted_logits, -1).cumsum(-1) # 累积概率
remove = cum - torch.softmax(sorted_logits, -1) > top_p # 加入前已超过 p 的去掉
sorted_logits[remove] = float('-inf')
logits = torch.full_like(logits, float('-inf')).scatter(-1, idx, sorted_logits)
probs = torch.softmax(logits, dim=-1) # 被屏蔽的 token 概率为 0
return torch.multinomial(probs, num_samples=1) # (B, 1)
所有截断手段都是「把 logit 设为 -inf 再 softmax」,和 causal mask 同一个套路;剩余概率自动重新归一化。
top-p 那行 cum - p_i > top_p 保证累积概率「刚跨过 p」的那个 token 仍被保留,候选集至少有 1 个。代入上面情形 B:第 4 个 token 的 cum − p_i = 0.92 − 0.12 = 0.80,不 > 0.9,保留;第 5 个 = 1.00 − 0.08 = 0.92 > 0.9,去掉。
最后的 scatter 把排好序、已屏蔽的 logits 按原始下标 idx 放回原来的位置(排序打乱了 token 顺序,采样结果必须对应真实 token id)。
torch.multinomial:按概率抽一个下标,概率 0.38 的 token 大约每 100 次抽中 38 次。
nanoGPT 的 sample.py 默认 temperature=0.8, top_k=200,没有实现 top-p。
工程视角 / 真实世界
API / 框架参数 :vLLM 的 SamplingParams 提供 temperature, top_p, top_k, min_p, repetition_penalty, presence_penalty, frequency_penalty, seed 等;HF generate() 在库层面默认 do_sample=False,即 greedy;但很多模型仓库的 generation_config.json 会覆盖成 do_sample=True 并给出推荐的 temperature/top_p,实际行为以它为准。
推理模型的推荐值 :DeepSeek-R1 官方建议 temperature 0.5–0.7(推荐 0.6)、top-p 0.95,并提示过低的 temperature 可能导致无尽重复——推理模型的长输出对解码参数更敏感。
Sampling 也是性能问题 :vocab 12.8 万量级时,每步对每个请求都做全排序(top-p)并不便宜;FlashInfer 等库提供了免排序的 top-k/top-p 采样 kernel,推理框架把 sampling 作为独立的 GPU 阶段优化。
与其他模块的联动 :structured output(JSON schema / 正则约束)同样是在采样前把不合法 token 的 logit 设为 -inf(第 46 课 SGLang 的结构化输出部分);speculative decoding(小模型先猜几个 token、大模型一次验证)的验证步骤要保证「接受后的分布」与目标模型的采样分布一致,因此必须知道采样参数(第 42 课)。
temperature=0 也不一定完全可复现 :服务端 batch 大小变化会改变浮点累加顺序,logits 出现微小差异,argmax 可能翻转。Thinking Machines 2025 年的博客 Defeating Nondeterminism in LLM Inference 专门讨论了这个问题(batch invariance)。
💬 能聊的要点
「模型只给 logits,生成质量很大程度由解码决定:temperature 调尖锐度不改排序,top-k/top-p/min-p 截掉长尾避免一步采错满盘皆输,penalty 抑制复读。」
「top-p 比 top-k 好在候选集随分布自适应——模型很确定时只剩一两个 token,不确定时放宽;min-p 则按最大概率的比例设阈值,高温下更稳。」
「所有这些策略在实现上都是『把 logit 置 -inf 再 softmax 采样』,structured output 也是同一套机制;对推理系统来说,sampling 是每个 decode step 都要跑的 GPU 阶段,大 vocab 下值得专门优化。」
常见误区
❌ temperature=0 输出一定完全一致 ✅ 算法上是 greedy,但浮点计算受 batch 组成等影响,服务端结果仍可能有差异。
❌ Beam search 比采样更高级,总是更好 ✅ 它在有标准答案的任务(翻译、ASR)里有用;在开放式对话/写作里容易产生乏味、重复的文本,LLM 聊天几乎都用采样。
❌ repetition_penalty 越大越好 ✅ 它会惩罚所有出现过的 token,包括代码和 JSON 里必需的重复符号;通常只在 1.0–1.2 左右微调。
自测(心算/口答即可)
top-p=0.9,概率分布为 [0.5, 0.3, 0.15, 0.05],保留哪些 token?答案 累积:0.5 → 0.8 → 0.95。到第 3 个时首次 ≥ 0.9,所以保留前 3 个,重新归一化为 [0.53, 0.32, 0.16]。
min_p=0.1,最大概率为 0.6,阈值是多少?答案 0.1 × 0.6 = 0.06,概率低于 0.06 的 token 全部去掉。
temperature 从 1 调到 0.5,会改变 greedy 的输出吗?答案 不会。temperature 只缩放 logits,不改变排序,argmax 不变;它只影响采样时的分布形状。
延伸
🎬 Karpathy:Let's build GPT 中 generate 的实现;Let's reproduce GPT-2 (124M) 中用 top-k=50 采样的部分;Deep Dive into LLMs like ChatGPT 中关于「模型是随机的 token 采样器」的讲解
🎬 李沐:zh.d2l.ai 第 9.8 节「束搜索」(greedy vs beam search);「GPT,GPT-2,GPT-3 论文精读」
📄 Holtzman et al., The Curious Case of Neural Text Degeneration (2019);Keskar et al., CTRL: A Conditional Transformer Language Model for Controllable Generation (2019);Nguyen et al., Turning Up the Heat: Min-p Sampling for Creative and Coherent LLM Outputs (2024)
下一课 → 18 · Scaling Laws:为什么越大越好,Chinchilla 说了什么
18 · Scaling Laws:为什么越大越好,Chinchilla 说了什么
⏱ 10 分钟 | 前置:17 · 解码策略 | 难度:★★☆
一句话
Loss(模型在预训练文本上预测下一个 token 的平均 cross-entropy,越低越好,见第 8 课)随参数量 N、数据量 D(训练 token 数)、算力 C(训练总 FLOPs)呈幂律(power law)平滑下降,所以可以用小模型实验「预言」大模型的效果;Chinchilla 进一步指出:给定算力,N 和 D 应该 等比例 增长(约 20 tokens/参数),而今天的模型为了推理便宜,故意把 D 堆到远超这个比例。
为什么要关心
训练一个大模型要几千张 GPU 跑几个月,不能靠试 。Scaling Laws 是「先用 1% 的钱做小实验,外推出该训多大、喂多少数据」的方法论,是整个预训练预算规划的地基。
它解释了一个 infra 现象:为什么 Llama-3-8B 要喂 15T tokens,为什么大家越来越关心推理成本而不是训练成本。
面试里「Chinchilla optimal」「over-training」「6ND」几乎是必考词汇。
核心直觉
1. 幂律:在 log-log 图上是一条直线
log(Loss)
│ ╲
│ ╲ 每把 N(或 D、C)翻 10 倍,
│ ╲ Loss 下降一个「固定比例」
│ ╲
│ ╲___ 逐渐逼近不可约误差(数据本身的熵)
└──────────────── log(N)
幂律长这样:L(N) ≈ E + A / N^α。E 是「再大也消不掉」的底线,A / N^α 是「还能靠变大消掉」的部分,α 是一个小于 1 的指数(Kaplan 拟合的 α_N 约 0.076)。两边取 log 后 log(L−E) 和 log N 是线性关系,所以画在 log-log 图上是直线——这就是「可外推」的来源:在小尺度上量出斜率,沿直线延长即可。
Kaplan et al. 2020(OpenAI, Scaling Laws for Neural Language Models )发现:只要其他两个因素不是瓶颈,Loss 对 N、D、C 分别是幂律关系,跨越 7 个以上数量级都很平滑。而且架构细节(层数 vs 宽度)影响很小 ,总参数量才是主导。这就是「越大越好」的实证基础——不是信仰,是拟合出来的曲线。
2. 算力账本:C ≈ 6ND
训练一个 dense(每个 token 都经过全部参数,区别于第 21 课的 MoE)Transformer 的 FLOPs 约等于 6 × 参数量 × token 数:forward 每个参数每个 token 约 2 FLOPs(一次乘加),backward 约是 forward 的 2 倍,合计 6。
这条公式要背下来,后面算 GPU 小时、MFU 都靠它。例:Llama 3 405B 用了约 3.8 × 10^25 FLOPs,代入 6 × 405e9 × 15.6e12 ≈ 3.8e25,对得上。
3. Chinchilla:Kaplan 把数据估少了
Kaplan 的结论暗示「算力增加时主要该加参数,数据加得慢」,于是 GPT-3(175B,约 300B tokens)、Gopher(280B,300B tokens)都是「大模型 + 相对少的数据」。
DeepMind 的 Hoffmann et al. 2022(Training Compute-Optimal Large Language Models ,即 Chinchilla 论文)重新做了 IsoFLOPs(等算力)实验:固定算力预算 ,扫不同的 N(D 随之由 C/6N 决定),看哪个 Loss 最低。
Loss 固定 C 的一条 IsoFLOP 曲线
│ ╲ ╱
│ ╲ ╱ ← 模型太大:数据不够,没训透
│ ╲____ ╱ ← 谷底 = compute-optimal N
│ 模型太小:容量不够
└──────────────────── N
结论:compute-optimal 时 N 和 D 应该同比例 增长,经验比例约 20 tokens / 参数 。他们用和 Gopher 相同算力训了 Chinchilla(70B, 1.4T tokens),全面超过 280B 的 Gopher。
🧩 难点拆解:幂律与 Chinchilla 的取舍
直觉 :固定预算 C = 6ND 就像固定一笔钱,要在「请更多员工(N)」和「让每人多干活(D)」之间分配。两头各有一个幂律项在收益递减:只加 N 不加 D,模型「没吃饱」;只加 D 不加 N,模型「装不下」。最优点是两个递减项的边际收益相等的地方。
小例子 :Chinchilla 论文给了一个拟合公式 L = 1.69 + 406.4/N^0.34 + 410.7/D^0.28(三项分别是:不可约误差、模型太小的惩罚、数据太少的惩罚)。固定 Gopher 的算力 C ≈ 5.76e23,代入几个 N:
N
D = C/6N
「模型小」项
「数据少」项
总 Loss
10B
9.6T
0.162
0.095
1.947
70B(Chinchilla)
1.4T
0.083
0.164
1.938
280B(Gopher)
0.34T
0.052
0.242
1.984
N 从 70B 涨到 280B,「模型小」项只少了 0.03,「数据少」项却多了 0.08——净亏。反过来缩到 10B 也变差,但差得不多:谷底很平 。(这个公式本身算出的最优点在 30–40B,论文里三种拟合方法结论略有出入,但都指向「同比例增长、约 20 tokens/参数」。)
一句话 :幂律 = 每翻 10 倍只换来固定比例的改进;Chinchilla = 在 N 和 D 两条递减曲线之间找平衡,而谷底平坦,正是后面「故意偏离最优点」的底气。
4. 现实:为什么大家都「over-train」
Chinchilla 优化的是训练 算力。但模型训完要服务亿万次请求,推理成本 ∝ 参数量。于是业界策略变成:用更小的模型、喂远超 20 倍的数据 ,多花一次性训练钱,换长期推理省钱。
模型
参数
训练 tokens
tokens/参数
Chinchilla
70B
1.4T
20
Llama 3 8B
8B
15T+
~1900
Llama 3 405B
405B
15.6T
~39
Qwen3 全系
0.6B–235B
36T
小模型上千
小模型远离 Chinchilla 点,Loss 仍在缓慢下降——只是边际收益变低。Llama 3 论文自己拟合的 scaling law 显示,3.8e25 FLOPs 的 compute-optimal 点约是 402B 参数 + 16.55T tokens,于是旗舰选了 405B;而 8B/70B 则是刻意 over-train。
5. 新维度:test-time compute
2024 年后(o1、DeepSeek-R1),scaling 多了一个轴:推理时多想一会儿 (更长的 CoT——Chain-of-Thought,先写出推理过程再给答案;或多次采样再挑最好的)也能换准确率。这对 infra 的含义是 decode 阶段的 token 量暴涨,长输出、KV Cache、吞吐成为瓶颈——这是 Part 5 的主题。
代码片段
# 用 C ≈ 6ND 做预算规划(纯算术,能心算最好)
def train_flops(n_params, n_tokens):
return 6 * n_params * n_tokens # forward 2 + backward 4
def gpu_days(flops, peak_tflops=989, mfu=0.4): # H100 BF16 dense 峰值约 989 TFLOPS
return flops / (peak_tflops * 1e12 * mfu) / 86400
def chinchilla_optimal(C):
# C = 6 N D 且 D ≈ 20 N => C = 120 N^2
N = (C / 120) ** 0.5
return N, 20 * N
C = train_flops(8e9, 15e12) # Llama-3-8B 量级:约 7.2e23
print(f"{C:.1e} FLOPs, {gpu_days(C):,.0f} H100-days") # 约 2.1 万 H100·天
N, D = chinchilla_optimal(C)
print(f"Chinchilla 会建议: {N/1e9:.0f}B 参数, {D/1e12:.1f}T tokens") # 约 77B / 1.5T
6 * N * D 只算矩阵乘,忽略 attention 的 O(L^2) 部分;上下文很长时要额外加。
mfu(Model FLOPs Utilization)是真实集群效率,大规模训练 30–50% 很常见,这个数是 infra 团队的 KPI。
同样的算力,Chinchilla 会建议训一个 ~77B 的模型;Meta 选择 8B + 15T,是在为推理成本买单。
工程视角 / 真实世界
小模型外推 :DeepSeek、Llama 3、Qwen 的技术报告都有 scaling law 章节,用 1e18–1e22 FLOPs 的小实验拟合曲线,再决定旗舰的 N、D、学习率和 batch size。DeepSeek LLM 论文还专门拟合了最优超参(lr、batch)随算力的 scaling 。
不只 loss :Llama 3 把 scaling law 从「预测 loss」延伸到「预测下游 benchmark 准确率」,两步拟合:FLOPs → NLL(negative log-likelihood,即在 benchmark 正确答案上的 loss)→ accuracy。
MoE 的 scaling :MoE 的「参数」要区分总参数和激活参数,6ND 里的 N 应该用激活参数 (每个 token 实际经过的参数),这也是 MoE 训练便宜的原因(第 21 课)。
数据墙 :高质量文本总量有限,over-train + 数据重复 + 合成数据成为 2024 年后的主线(第 27 课)。
💬 能聊的要点
「Scaling Laws 的价值不是『越大越好』这句口号,而是 loss 可预测:用小一两个数量级的实验拟合幂律,就能在花大钱之前定下模型规模、数据量和超参。」
「Chinchilla 说 compute-optimal 大约 20 tokens/参数,但它只优化训练成本;现代模型把推理成本计入,普遍 over-train,比如 Llama-3-8B 喂了 15T tokens,接近 2000 tokens/参数。」
「训练 FLOPs 约等于 6ND,其中 MoE 的 N 取激活参数;配合 GPU 峰值和 MFU,就能粗算出一个训练任务需要多少 GPU·天。」
常见误区
❌ Chinchilla 证明了「模型不该做大」。✅ 它说的是给定算力下 N 和 D 要平衡;算力增加时两者都该增长。
❌ 超过 Chinchilla 比例的数据是浪费。✅ Loss 仍在下降,只是单位算力收益变低;考虑推理成本时,over-train 往往是更优解。
❌ Scaling Laws 能预测任何能力。✅ 它对 pretraining loss 很准,对具体下游能力(尤其所谓「涌现」能力)的预测误差大得多。
自测(心算/口答即可)
一个 70B dense 模型训 2T tokens,大约多少 FLOPs?答案 6 × 70e9 × 2e12 = 8.4 × 10^23 FLOPs。
同样算力下,为什么 Meta 不按 Chinchilla 比例把 8B 的算力拿去训一个 ~77B 模型?答案 8B 模型推理便宜近 10 倍(显存、延迟、吞吐都好),能在单卡部署;多花的训练算力是一次性成本,推理成本是持续成本。
MoE 模型 671B 总参数、37B 激活,训练算力估算用哪个 N?答案 用激活参数 37B,因为每个 token 只经过这些参数的计算。
延伸
🎬 Karpathy:Let's reproduce GPT-2 (124M) 中讨论 GPT-3 论文超参与 token 预算的部分;Deep Dive into LLMs like ChatGPT 的 pretraining 章节
🎬 李沐:「跟李沐学AI」论文精读 GPT/GPT-2/GPT-3、Llama 3.1(含 scaling law 章节讨论)
📄 Kaplan et al. 2020, Scaling Laws for Neural Language Models (arXiv:2001.08361)
📄 Hoffmann et al. 2022, Training Compute-Optimal Large Language Models (arXiv:2203.15556)
📄 Llama Team 2024, The Llama 3 Herd of Models §3.2.1 (arXiv:2407.21783)
下一课 → 18a · Scaling Laws 实战:IsoFLOP 拟合、μP、超参外推与 WSD
18a · Scaling Laws 实战:IsoFLOP 拟合、μP、超参外推与 WSD
⏱ 10 分钟 | 前置:18 · Scaling Laws | 难度:★★★ | 🆕 对标 Stanford CS336 Lecture 3 / 9 / 11 与 Assignment 3
一句话
第 18 课回答「该训多大、喂多少数据」;真正开训前还要定 learning rate、batch size、模型形状 ,而且旗舰只训一次、没法试。业界的做法是三件工具:IsoFLOP 拟合 (用一串小实验画出 N_opt 随算力的直线再外推)、μP (换一种参数化,让小模型上调好的 lr 直接搬到大模型)、超参自己的 scaling law + WSD 调度 (lr/batch 随算力外推,一条训练曲线复用出多个数据量的结果)。
为什么要关心
CS336 Assignment 3 的题目就是:给你一个有 FLOPs 预算的「训练 API」,用小实验拟合 scaling law,预测大预算下的最优模型和 loss。这是预训练团队每天在做的事。
技术报告里「我们在 1e18–1e22 FLOPs 上做了 scaling 实验」「采用 WSD schedule」「使用 μP」这类句子,读懂它们才知道一份报告的方法论靠不靠谱。
对 infra 来说,这些小实验是大量的短作业:几百个几小时的 run,调度、checkpoint 分支、结果汇总都是平台工作。
核心直觉
1. 拟合一条 scaling law 的标准流程(IsoFLOP 法)
第 1 步:选 5–6 个小算力预算 C(如 1e18 ~ 1e20 FLOPs)
第 2 步:每个 C 扫一排模型大小 N,D = C / 6N,训到底,记下 loss
Loss
│ ╲ C=1e18 ╱
│ ╲__●__╱ C=1e19 ╲___●___╱ ← 每条曲线的谷底 = N_opt(C)
└──────────────────────────────── log N
第 3 步:把 (C, N_opt) 画在 log-log 图上,拟合直线 N_opt = k · C^a
第 4 步:沿直线外推到目标预算(如 1e24),得到 N 和 D = C/6N
Chinchilla 论文的「方法 2」、Llama 3 报告 §3.2.1 都是这个流程。两个细节决定成败:
每个点都要「训到位」 :Kaplan 2020 低估了数据的作用,一个主要原因是所有 run 用同一条 cosine 调度长度,短 run 没衰减完就被截断,loss 偏高。Chinchilla 为每个 D 单独设置调度长度,才得到「约 20 tokens/参数」。
N 怎么数 :Kaplan 只数非 embedding 参数,Chinchilla 数全部参数。小模型里 embedding 占比很大,口径不同会让拟合出的指数系统性偏移——这也是两篇论文结论打架的另一半原因(Pearce & Song 2024 专门做了「对账」)。
2. 为什么不能直接把小模型的 lr 搬到大模型
标准参数化(SP,也就是 PyTorch 默认初始化 + 全局同一个 lr)下,模型变宽,最优 lr 会变小 。原因:AdamW 每步对每个权重的改动幅度约等于 lr(第 09 课:Adam 把梯度归一化了);一个宽度为 d 的矩阵乘会把 d 个这样的改动累加到输出上,宽度翻倍,每步对激活的扰动也跟着放大。
SP(标准参数化) μP
loss loss
│ ╲ width=256 │╲ ╲ ╲
│ ╲╱ ╲ width=1024 │ ╲___╲___╲___ 各宽度的谷底对齐在同一个 lr
│ ╲╱ ╲ width=4096 │
│ ╲╱ ← 谷底一路左移 │
└──────────────── log lr └──────────────── log lr
μP(Maximal Update Parametrization) 的做法:按宽度相对于一个「base width」的倍数 m,有规则地缩放初始化和各层 lr。对 Adam 只需记三条:
隐藏层矩阵的 lr 乘 1/m(宽了就走小步);
输出层 logits 乘 1/m(或等价地缩小 unembedding 的初始化);
attention 的缩放从 1/√d_head 改为 1/d_head。
效果:在约 40M 参数的代理模型上扫好 lr、init、warmup,直接迁移到 6.7B(Yang et al. 2022 的 μTransfer 实验)。Cerebras-GPT、MiniCPM 等公开报告用了 μP 系列方法;更多团队不用 μP,而是走下一节的路线。
3. 另一条路:给超参也拟合 scaling law
DeepSeek LLM(2024)在 SP 下扫了大量 (lr, batch) 组合,拟合出最优值随算力 C 的幂律(论文里的 C 用「每 token 非 embedding FLOPs × token 数」计算,和 6ND 口径略有不同):
η_opt = 0.3118 · C^(-0.1250) 算力越大,lr 越小
B_opt = 0.2920 · C^( 0.3271) 算力越大,batch(按 token 数)越大
代入 C = 1e24:η_opt ≈ 3.1e-4,B_opt ≈ 2.1e7 tokens(约 5000 条 4K 序列)。和第 02 课说的「LLM 峰值 lr 在 1e-4 ~ 6e-4」对得上。
batch 为什么随算力变大?McCandlish et al. 2018 的 critical batch size :batch 小于它时,翻倍 batch ≈ 步数减半、总 FLOPs 不变(纯赚并行度);超过它就是浪费算力。loss 越低、梯度噪声相对越大,critical batch size 越大——这也是大模型训练能铺到上万张卡做数据并行的前提(第 29 课)。
4. WSD:一次训练拿到多个数据量的结果
Cosine 调度要提前知道总步数;想知道「同一个模型训 1T / 2T / 4T tokens 分别多少 loss」,就得从头跑 3 次。WSD(Warmup-Stable-Decay) 把调度拆成三段:
lr
│ ┌────────────────────────────┐
│ ╱ Stable:恒定 lr │╲ Decay(最后 ~10–20%)
│ ╱ ├──╲ │ ╲ ← 从任意 stable checkpoint
│╱ Warmup │ ╲ │ ╲ 分叉出去单独做 decay
└────────────────────────────────────── step
1T 分叉 ↑ 2T 分叉 ↑ 4T
loss 在 stable 段缓慢下降,进入 decay 段会明显「掉一截」;decay 完的 loss 和同长度 cosine 基本持平(Hägele et al. 2024)。
主干只跑一次,每个分叉只多花约 10–20% 的步数,就得到多个数据量的「训到位」结果——正好喂给第 1 节的拟合,也方便旗舰训练中途决定「要不要再多训一段」。
MiniCPM(2024)把它带火;之后不少开源模型的报告采用 WSD 或类似的「常数 + 末段退火」调度,退火阶段还常换成更高质量的数据(第 27 课)。
5. 形状超参:有经验区间,不必死磕
Kaplan 2020 发现:总参数固定时,深宽比在很宽的范围内对 loss 影响很小。所以形状超参更多是「对齐硬件 + 沿用经验」(CS336 Lecture 3 专门统计了一批开源模型):
超参 常见取值 Llama-3-8B / 70B 实例
FFN 宽度 / d_model GELU 约 4;SwiGLU 约 8/3 ~ 3.5(第 15 课) 14336 / 4096 = 3.5;28672 / 8192 = 3.5
head_dim 64 ~ 128,n_head × head_dim ≈ d_model 128 / 128
d_model / n_layers(宽深比) 约 100 ~ 200 4096/32 = 128;8192/80 ≈ 102
vocab 早期 32K~50K,现在 128K~256K(多语言、代码) 128,256
weight decay / grad clip 0.1 / 1.0 0.1 / 1.0
维度取 64 或 128 的倍数是为了让矩阵乘对齐 Tensor Core 的 tile(第 35 课);Karpathy 把 GPT-2 的 vocab 从 50,257 pad 到 50,304 就提速了一截,就是这个原因。
🧩 难点拆解:外推到底在赌什么
直觉 :scaling law 外推相当于在 log-log 图上拿尺子把直线延长 4 个数量级。尺子本身很准(幂律跨 7 个数量级都成立),风险在于小实验的条件和大训练不一致 :lr 没按规模调、调度没训到位、数据配比变了、N 的口径不同。
代入数字 :用下面的代码,把 Chinchilla 公式当作「黑盒训练」,在 1e18–1e20 的 5 个预算上做 IsoFLOP:
拟合出 N_opt ∝ C^0.46(理论值 β/(α+β) = 0.28/0.62 ≈ 0.45,说明流程本身没问题);
外推到 1e24 FLOPs,得到约 47B 参数、3.5T tokens ,约 75 tokens/参数——比「20 倍」大,因为这套公式拟合出的最优比例本来就随算力漂移(第 18 课提到它算出的最优点和论文另两种方法有出入);
如果小实验的 lr 在小模型上偏大、在大模型上偏小,每个谷底都会被系统性地推偏,直线的斜率 跟着错——外推 4 个数量级后,误差会放大到「该训 40B 却训了 80B」的程度。
一句话 :拟合本身是两次 polyfit;难的是保证每个小实验都「在它自己的规模上调到最优」,μP 和超参 scaling law 就是为此服务的。
代码片段
import numpy as np
def loss(N, D): # 假装这是「真实训练」:用 Chinchilla 拟合公式当黑盒
return 1.69 + 406.4 / N**0.34 + 410.7 / D**0.28
budgets = [1e18, 3e18, 1e19, 3e19, 1e20] # 5 个小算力预算
opt = []
for C in budgets:
Ns = np.logspace(7, 10, 25) # 每个预算扫 25 个模型大小:10M ~ 10B
Ls = loss(Ns, C / (6 * Ns)) # D 由 C = 6ND 决定
a, b, c = np.polyfit(np.log(Ns), Ls, 2) # IsoFLOP 曲线 ≈ log N 的抛物线
opt.append(np.exp(-b / (2 * a))) # 抛物线谷底 = 该预算下的 N_opt
slope, icpt = np.polyfit(np.log(budgets), np.log(opt), 1) # log N_opt = slope·log C + icpt
print(f"N_opt ∝ C^{slope:.2f}") # N_opt ∝ C^0.46
C_big = 1e24
N_big = np.exp(icpt) * C_big**slope
print(f"外推到 1e24 FLOPs: N ≈ {N_big/1e9:.0f}B, D ≈ {C_big/6/N_big/1e12:.1f}T tokens")
# 外推到 1e24 FLOPs: N ≈ 47B, D ≈ 3.5T tokens
真实场景里 loss(N, D) 是一次几小时到几天的训练,25 × 5 = 125 个 run 就是一批调度任务;CS336 A3 用「训练 API + FLOPs 配额」模拟这个约束,逼你思考把预算花在哪几个点上 。
对抛物线取谷底,而不是直接取 25 个点里 loss 最小的那个:真实 loss 有噪声,拟合曲线比取单点稳。
第二次 polyfit 是在 log-log 空间拟合直线,对应「幂律 = log-log 直线」(第 18 课)。
工程视角 / 真实世界
预算分配 :scaling 实验通常只占旗舰算力的一小部分,但要覆盖 3–4 个数量级;最大的小实验离目标越近,外推越可信。Llama 3 用 6e18–1e22 FLOPs 的实验外推到 3.8e25。
WSD 对平台的要求 :主干 checkpoint 要频繁保存且可分叉,分叉 run 继承优化器状态;这让「训练任务」从一条线变成一棵树,实验管理系统要能追踪谱系。
batch 与集群 :critical batch size 给数据并行规模设了上限——batch 超过它,加卡只会浪费算力,此时只能靠 TP/PP 等模型并行继续扩(第 30 课)。
新优化器也要重做 scaling :换优化器(如 Muon)或换架构(如 MoE)后,最优 lr/batch 的规律要重新拟合;MoE 的 N 用激活参数(第 18、21 课)。
💬 能聊的要点
「拟合 scaling law 用 IsoFLOP:每个小预算扫 N 取谷底,再在 log-log 上拟合 N_opt 对 C 的直线外推。关键是每个小实验都要训到位,Kaplan 和 Chinchilla 结论不同,很大程度就是调度长度和参数口径的问题。」
「标准参数化下,模型变宽最优 lr 会变小;μP 按宽度缩放各层 lr 和初始化,让小模型调好的超参直接迁移。不用 μP 的团队,就给 lr 和 batch 也拟合幂律,比如 DeepSeek LLM。」
「WSD 调度用恒定 lr 跑主干,最后 10–20% 快速衰减;从主干任意点分叉做 decay,一次训练就能得到多个数据量的结果,很适合做 data scaling 实验。」
常见误区
❌ 小模型上调好的 lr 可以直接用在大模型上。✅ 标准参数化下最优 lr 随宽度下降,直接照搬大模型容易发散;要么用 μP,要么按超参 scaling law 外推。
❌ batch 越大越好,反正卡多。✅ 超过 critical batch size 后,翻倍 batch 几乎不再减少所需步数,等于浪费一半算力。
❌ WSD 的 stable 段 loss 比 cosine 高,所以它更差。✅ 要比 decay 完的 loss;decay 段会「掉一截」,最终和同长度 cosine 基本持平。
自测(心算/口答即可)
IsoFLOP 曲线的横轴、纵轴分别是什么?谷底代表什么?答案 固定算力 C,横轴是模型大小 N(log),纵轴是训练完的 loss;谷底是这个算力下的 compute-optimal 模型大小 N_opt,对应的 D = C/6N。
为什么 Kaplan 2020 会低估数据的重要性?说出两个原因。答案 ① 所有 run 用同一条 cosine 调度长度,数据少的 run 没衰减完,loss 偏高,显得「多训数据收益不大」;② 只数非 embedding 参数且实验规模偏小,口径差异让拟合指数偏移。
按 DeepSeek LLM 的公式,算力从 1e22 增加到 1e24(100 倍),最优 lr 变为原来的多少?答案 100^(-0.125) = 10^(-0.25) ≈ 0.56,约降到原来的一半多一点。
延伸
🎓 Stanford CS336:Lecture 3(Architectures, hyperparameters)、Lecture 9 / 11(Scaling laws)、Assignment 3(Scaling)——见附录 CS336 对照与动手路线
🎬 李沐:「跟李沐学AI」Llama 3.1 论文精读中的 scaling law 章节
📄 Yang et al. 2022, Tensor Programs V: Tuning Large Neural Networks via Zero-Shot Hyperparameter Transfer (μP / μTransfer,arXiv:2203.03466)
📄 DeepSeek-AI 2024, DeepSeek LLM: Scaling Open-Source Language Models with Longtermism (超参 scaling law,arXiv:2401.02954)
📄 Hu et al. 2024, MiniCPM: Unveiling the Potential of Small Language Models with Scalable Training Strategies (WSD,arXiv:2404.06395)
📄 McCandlish et al. 2018, An Empirical Model of Large-Batch Training (critical batch size,arXiv:1812.06162)
📄 Hägele et al. 2024, Scaling Laws and Compute-Optimal Training Beyond Fixed Training Durations (WSD vs cosine)
下一课 → 19 · MQA / GQA:给 KV Cache 瘦身
19 · MQA / GQA:给 KV Cache 瘦身
⏱ 10 分钟 | 前置:18 · Scaling Laws | 难度:★★☆
一句话
MHA(Multi-Head Attention,第 12 课)每个 Query head 都有自己的 K/V head;MQA 让所有 Query head 共享一个 K/V head,GQA 折中为分组共享 ——KV Cache 缩小 n_head / n_kv_head 倍,质量几乎不掉,是今天几乎所有 dense 模型的默认配置。
为什么要关心
Decode(逐个生成新 token 的阶段)每生成一个 token,都要把整个 KV Cache(之前所有 token 每层算好的 K、V 向量,缓存起来避免重算,第 37 课)从 HBM(GPU 显存)读一遍 。Decode 是 memory-bound(瓶颈在读显存的带宽,而不是算力),KV Cache 越大,越慢、能并发的请求越少。
KV Cache 的大小直接决定:一张卡能开多大的 batch、能支持多长的 context、vLLM 的 block 能分多少个。
看到 config.json 里 num_key_value_heads: 8,你要能立刻算出每 token 的 KV Cache 字节数。
核心直觉
1. 三种布局一张图
MHA (n_kv = n_head) GQA (n_kv = 分组数) MQA (n_kv = 1)
Q: ■ ■ ■ ■ ■ ■ ■ ■ Q: ■ ■ ■ ■ ■ ■ ■ ■ Q: ■ ■ ■ ■ ■ ■ ■ ■
│ │ │ │ │ │ │ │ └┬┘ └┬┘ └┬┘ └┬┘ └──────┬──────┘
K: □ □ □ □ □ □ □ □ K: □ □ □ □ K: □
V: □ □ □ □ □ □ □ □ V: □ □ □ □ V: □
cache 8 份 cache 4 份(2 个 Q 共享) cache 1 份
Query 仍然是完整的多头——每个头依然「用不同角度去问」;只是被问的「资料库」K/V 变少了。直觉上,K/V 头之间本来就有大量冗余,共享掉损失不大。
2. KV Cache 账本(背下这个公式)
每 token KV Cache = 2 (K和V) × n_layers × n_kv_heads × head_dim × bytes_per_elem
以 Llama-3-8B 为例(config:32 层、32 个 Q head、8 个 KV head 、head_dim 128、BF16 = 2 字节):
方案
n_kv_heads
每 token
8K context / 条
128K context / 条
MHA(假设)
32
2×32×32×128×2 = 512 KiB
4 GiB
64 GiB
GQA(实际)
8
2×32×8×128×2 = 128 KiB
1 GiB
16 GiB
MQA(假设)
1
16 KiB
128 MiB
2 GiB
对比权重本身:8B × 2 字节 ≈ 16 GB。如果是 MHA,4 条 8K 请求的 KV Cache 就等于整个模型权重 。GQA 把这个数字直接砍到 1/4。
再看 Llama-3-70B (80 层、64 个 Q head、8 个 KV head、head_dim 128):每 token = 2×80×8×128×2 = 320 KiB ;如果是 MHA 会是 2.5 MiB,缩小 8 倍。
3. 为什么不直接用 MQA?
质量 :MQA 只有一个 K/V,表达能力损失可测;训练也更容易不稳定。
Tensor Parallel 不友好 :TP(Tensor Parallel,把一层的权重切到多张卡上并行算,第 30 课)对 attention 是按 head 切分的。MQA 只有 1 个 KV head,TP=8 时每张卡都得存一份完整副本,省下的显存又被复制回来了。GQA 用 8 个 KV head,正好和单机 8 卡 TP 对齐,每卡 1 个 KV head。
GQA 论文(Ainslie et al. 2023)的结论:GQA 质量接近 MHA、速度接近 MQA。还给了一个工程技巧:把已训好的 MHA checkpoint 的 K/V 头按组 mean-pool (组内几个头的权重取平均,合成一个头),再用约 5% 的原始预训练算力「uptrain」(在新结构上继续训一小段让它适应),就能转成 GQA。
4. 历史脉络
2019:Shazeer 提出 MQA(Fast Transformer Decoding: One Write-Head is All You Need ),PaLM、Falcon 等采用。
2023:GQA 提出,Llama 2 70B 采用(7B/13B 还是 MHA);Mistral 7B 用 8 个 KV head。
2024 至今:Llama 3 全系、Qwen2/3、gpt-oss 都是 GQA;DeepSeek 走了另一条路 MLA(下一课)。
代码片段
# GQA 的核心:K/V 头数少,计算前把它们「复制」对齐到 Q 的头数
def gqa_attention(x, Wq, Wk, Wv, n_head=32, n_kv=8, hd=128):
B, T, _ = x.shape
q = (x @ Wq).view(B, T, n_head, hd).transpose(1, 2) # (B, 32, T, hd)
k = (x @ Wk).view(B, T, n_kv, hd).transpose(1, 2) # (B, 8, T, hd) ← 只 cache 这个
v = (x @ Wv).view(B, T, n_kv, hd).transpose(1, 2) # (B, 8, T, hd) ← 只 cache 这个
rep = n_head // n_kv # 每组 4 个 Q 共享一个 KV
k = k.repeat_interleave(rep, dim=1) # (B, 32, T, hd) 逻辑展开
v = v.repeat_interleave(rep, dim=1)
att = (q @ k.transpose(-2, -1)) / hd ** 0.5
att = att.masked_fill(causal_mask[:T, :T] == 0, float('-inf')).softmax(-1)
return (att @ v).transpose(1, 2).reshape(B, T, n_head * hd)
Wk、Wv 的输出维度只有 n_kv * hd(8×128=1024),而 Wq 是 4096,所以 GQA 也顺带减少了一点参数。
repeat_interleave 在 HF transformers 里叫 repeat_kv;生产 kernel(FlashAttention、FlashInfer)不会真的复制内存,而是让 4 个 Q 头读同一块 K/V。
被写进 KV Cache 的是复制之前 的 k、v,这就是省显存的来源。
工程视角 / 真实世界
config.json 对照 :num_attention_heads: 32, num_key_value_heads: 8 → GQA,组大小 4。Qwen3-235B-A22B 是 64 / 4(组大小 16),gpt-oss-120b 是 64 / 8 且 head_dim 只有 64。
Decode 算术强度提升 :算术强度 = 每从显存读 1 字节做多少次计算。同一份 K/V 被 4 个 Q 头复用,读一次算 4 次,GQA 让 decode 的 attention 更不那么 memory-bound。
TP 上限 :KV head 数常是 TP 度的天然上限。TP 度超过 n_kv_heads 时,vLLM/SGLang 会把 KV head 复制到多张卡上,KV Cache 的节省会打折扣——这是部署大模型时要留意的点。
vLLM 的显存规划 :启动时 profile 出剩余显存 ÷ 每 token KV 字节 = 能容纳的 token 总数,也就是 PagedAttention 的 block 数(第 37、38 课)。
💬 能聊的要点
「GQA 是 MHA 和 MQA 的折中:Query 保持多头,K/V 分组共享,KV Cache 缩小 n_head/n_kv_head 倍,质量几乎不掉。」
「Llama-3-8B 每 token KV Cache 是 2×32 层×8 头×128×2B = 128KiB,8K 上下文一条请求就是 1GiB;如果是 MHA 要 4 倍。」
「KV head 数通常取 8,一个重要原因是和单机 8 卡 TP 对齐;MQA 在 TP 下反而要复制 KV,省不下显存。」
常见误区
❌ GQA 减少了 attention 的计算量。✅ 主要省的是 KV Cache 显存和带宽 ;QK^T 的计算量仍按 Q 头数算,基本不变。
❌ GQA 需要从头训练。✅ 可以从 MHA checkpoint mean-pool 后少量 uptrain 得到(GQA 论文做法),当然主流模型现在都是从头就用 GQA。
❌ KV Cache 和 batch 无关。✅ 它是每条序列每个 token 都有一份,总量 = 每 token 字节 × Σ 所有请求的长度。
自测(心算/口答即可)
Qwen3-8B:36 层、8 个 KV head、head_dim 128、BF16,每 token KV Cache 多大?答案 2×36×8×128×2 = 147,456 B = 144 KiB。
一张 80GB 卡跑 Llama-3-8B(权重约 16GB),假设剩 60GB 全给 KV Cache,最多容纳多少 token?答案 60 GiB ÷ 128 KiB ≈ 49 万 token,例如约 60 条 8K 请求(粗算,忽略激活与碎片)。
为什么 GQA 对 prefill 的速度帮助不大、对 decode 帮助大?答案 Prefill 是 compute-bound,主要花在矩阵乘上;decode 每步都要读全部 KV Cache,是 memory-bound,KV 变小直接减少读取字节数。
延伸
🎬 Karpathy:Let's build GPT: from scratch, in code, spelled out. 中 multi-head attention 的实现部分(本课是在它之上改 K/V 头数)
🎬 李沐:《动手学深度学习》「注意力机制」章的「多头注意力」小节
📄 Shazeer 2019, Fast Transformer Decoding: One Write-Head is All You Need (arXiv:1911.02150)
📄 Ainslie et al. 2023, GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints (arXiv:2305.13245)
下一课 → 20 · MLA:DeepSeek 的低秩 KV 压缩
20 · MLA:DeepSeek 的低秩 KV 压缩
⏱ 10 分钟 | 前置:19 · MQA / GQA | 难度:★★★
一句话
MLA(Multi-head Latent Attention)不再缓存每个头的 K/V,而是缓存一个低维 latent 向量 (DeepSeek-V3 里 512 维)+ 一个共享的 64 维 RoPE key;推理时通过 weight absorption 直接在 latent 空间做 attention——KV Cache 比 GQA 还小,效果却不输 MHA。
为什么要关心
GQA 省显存的方式是「减少 KV 头数」,本质是牺牲表达力换显存 。MLA 换了思路:K/V 本身是低秩的,存压缩后的版本就行 。
DeepSeek-V2/V3/R1、Kimi K2 都用 MLA。部署这些模型时,vLLM/SGLang 的 attention backend、并行策略(DP-Attention)都是围绕 MLA 特殊设计的,不懂 MLA 就看不懂这些代码。
核心直觉
1. 从「存结果」到「存原料」:低秩压缩
先定几个符号(DeepSeek-V3 的取值):h_t 是第 t 个 token 进入 attention 前的 hidden state(7168 维);128 个头,每头 K/V 维度 128。
MHA 缓存的是每个头算好的 K、V:每 token 每层 2 × 128 头 × 128 = 32,768 个数。MLA 先做一次下投影 (down-projection,一个 7168→512 的矩阵乘)得到一个小向量 c_KV,叫 latent (潜向量,「压缩后的中间表示」):
h_t (7168)
│ W_DKV (7168×512,down-proj)
▼
c_KV (512) ←─────── 只 cache 这个(+ 第 2 节的 k_rope)
│ │
W_UK │ │ W_UV (up-proj,每个头各自一份 512×128)
▼ ▼
K_nope V
(128头×128) (128头×128)
「低秩(low-rank)」的意思:原本从 h 直接到全部 K 的矩阵是 7168×16384,现在被拆成 7168×512 乘 512×16384 两段,中间被「挤」到 512 维。赌的是 K/V 里真正有用的信息本来就不超过 512 维——就像一张图存成 JPEG 也看不出差别。
类比:不存每个人各自抄的笔记(K/V per head),只存一份「原始录音」的压缩版(c_KV),谁要用谁自己「解压」。
🧩 难点拆解:Weight absorption——为什么可以「不解压」
直觉 :如果每步 decode 都要把 cache 里 T 个 c_KV 用 W_UK 解压回 K,那显存是省了,算力却爆了。关键观察:W_UK 对所有历史 token 都是同一个固定矩阵 。与其把 T 个 key 各「解压」一次,不如把当前这 1 个 query「压缩」一次 ,在 512 维的 latent 空间里直接比。这只是矩阵乘法结合律——换个括号位置。
类比:数据库里一列存的是编码后的值,要找「等于 X」的行,不会把全表逐行解码再比较,而是把 X 编码一次,直接和编码值比。(不成立之处:这里的「编码」是线性投影,点积在投影前后恰好守恒;一般的压缩算法没有这个性质。)
看 shape (单个头 i,行向量写法,和下面代码一致):
量
shape
说明
c_t
(512,)
历史 token t 的 latent,cache 里存的就是它
W_UK[i]
(512, 128)
头 i 的 key 上投影,固定权重
k_t = c_t @ W_UK[i]
(128,)
「解压」出的 key(我们想避免 算它)
q
(128,)
当前 token 在头 i 的 query
q_lat = q @ W_UK[i].T
(512,)
把 query「压」进 latent 空间,每步只算 1 次
score_t = q · k_t = q · (c_t @ W_UK[i]) = (q @ W_UK[i].T) · c_t = q_lat · c_t
小例子 (把 512 缩成 2、128 缩成 3):c = [1, 2],W_UK = [[1,0,2],[0,1,1]],q = [1,1,1]。
解压路线:k = c @ W_UK = [1, 2, 4],q · k = 7
吸收路线:q_lat = q @ W_UK.T = [3, 2],q_lat · c = 3 + 4 = 7,结果一样。
账算下来 (单层、单头数 128、上下文 T = 32K):解压路线每步要算 T × 128 头 × 512 × 128 ≈ 2.7×10^11 次乘加,还要临时生成约 1 GiB 的 K;吸收路线的额外代价只有 128 头 × 128 × 512 ≈ 8.4×10^6,与 T 无关。
V 侧同理:输出 o = Σ_t p_t · (c_t @ W_UV[i]) = (Σ_t p_t · c_t) @ W_UV[i]——先在 latent 空间按 attention 权重 p_t 加权求和,最后只上投影 1 次;这一步还能继续并进输出投影 W_O。
吸收后,decode 阶段的 MLA 在形态上就是一个 MQA (第 19 课):128 个 query 头共享同一个「KV 头」,这个头的 K 是 576 维(512 latent + 64 rope,见第 2 节)、V 是 512 维。
一句话 :因为 W_UK / W_UV 与 token 位置无关,可以把它们从「T 个 key 那一侧」挪到「1 个 query 那一侧」,于是 cache 永远保持压缩态。
2. Decoupled RoPE:为什么要单独留 64 维
RoPE(第 13 课)会把 q 和 k 按各自的位置旋转:key 在位置 t 被乘上旋转矩阵 R_t。如果直接对解压出的 key 加 RoPE:k_t = (c_t @ W_UK[i]) @ R_t,那么
score_t = (q @ R_s) · (c_t @ W_UK[i] @ R_t) = (q @ R_s @ R_tᵀ @ W_UK[i]ᵀ) · c_t
中间那个矩阵 R_tᵀ W_UK[i]ᵀ 随每个历史位置 t 都不一样 ——不再有一个「对所有 token 通用」的矩阵可以挪到 query 侧,又得对 T 个 token 各算一次,absorption 失效。
DeepSeek 的解法:把位置信息拆出去 单独走一条小通道。
k_nope(128 维/头,nope = no position):来自 c_KV,不加 RoPE,可以被吸收。
k_rope(64 维):直接从 h_t 投影得到,写入 cache 时就旋转好,所有头共享 ,单独缓存。
每个头的 query 也拆成 q_nope(128) + q_rope(64),分数 = q_lat · c_t + q_rope · k_rope_t:前一项负责「内容匹配」,后一项负责「相对位置」。
这就是 config.json 里 qk_nope_head_dim: 128、qk_rope_head_dim: 64、kv_lora_rank: 512 的含义。Query 侧也做了低秩压缩(q_lora_rank: 1536),但那是为了省训练激活显存,与 KV Cache 无关。
3. 账本:DeepSeek-V3(61 层,128 头,BF16)
方案
每层每 token 元素数
每 token(61 层)
MHA(同维度假设:K 192 + V 128,128 头)
128×(192+128) = 40,960
≈ 4.8 MiB
GQA-8(head_dim 128,同 61 层假设)
2×8×128 = 2,048
244 KiB
MLA
512 + 64 = 576
576×61×2 B ≈ 68.6 KiB
对比 Llama-3-70B(GQA)每 token 320 KiB:一个 671B 的模型,每 token KV Cache 只有 70B 模型的约 1/5。DeepSeek-V2 论文的表述是:MLA 的 KV Cache 相当于只有 2.25 组的 GQA ,但效果强于 MHA;相比 DeepSeek 67B(dense 模型),KV Cache 减少 93.3%,最大生成吞吐提升到 5.76 倍。
代码片段
# MLA decode 一步(已做 weight absorption,单层、单 token 示意,省略 batch 与 q 的低秩压缩)
d_c, d_rope, d_nope, d_v, H = 512, 64, 128, 128, 128
def mla_decode(h_t, cache_ckv, cache_krope, pos):
# 1) 为新 token 生成要缓存的东西:一个 latent + 一个共享 rope key
c_kv = rms_norm(h_t @ W_DKV) # (512,)
k_rope = rope(h_t @ W_KR, pos) # (64,) 所有头共享
cache_ckv.append(c_kv); cache_krope.append(k_rope)
C, KR = stack(cache_ckv), stack(cache_krope) # (T,512), (T,64)
out = []
for i in range(H):
q_nope, q_rope = split(h_t @ W_Q[i]) # (128,), (64,)
q_rope = rope(q_rope, pos)
q_lat = q_nope @ W_UK[i].T # (512,) ← absorption:q 投到 latent 空间
score = (C @ q_lat + KR @ q_rope) / (d_nope + d_rope) ** 0.5
o_lat = softmax(score) @ C # (512,) 在 latent 空间加权求和
out.append(o_lat @ W_UV[i]) # (128,) 实际实现里 W_UV 也并进 W_O
return concat(out) @ W_O
缓存的只有 C(512 维)和 KR(64 维),与头数 H 无关——这是 MLA 省显存的根本。
q_lat = q_nope @ W_UK[i].T 就是 absorption:不解压 K,改为把 Q 「压」进 latent 空间。
Decode 时所有头读的是同一份 C,访存一次、计算 128 次,算术强度远高于 GQA。
循环里没有任何一行把 C 乘 W_UK 或 W_UV——这就是「不解压」在代码里的样子;W_UK[i] 只作用在 1 个 query 上。
工程视角 / 真实世界
Prefill 和 decode 走不同路径 :prefill 是 compute-bound,通常把 c_KV 解压成普通 MHA 计算(每头 192 维 QK)更省 FLOPs;decode 是 memory-bound,用 absorption 形态(MQA-like,576 维)更省带宽。vLLM、SGLang 的 MLA backend 都会按阶段切换。
专用 kernel :DeepSeek 在 2025 年 2 月开源了 FlashMLA(针对 Hopper 的 MLA decode kernel);FlashInfer 也有 MLA 支持。
TP 的尴尬 :latent 被所有头共享,按头切 TP 时每张卡都得存一份完整 c_KV,KV Cache 没法随 TP 线性切分。所以 SGLang 对 DeepSeek 推荐 DP-Attention (attention 部分做数据并行、每卡处理不同请求,MoE 部分做 EP),见第 44 课。
后续演进 :Kimi K2 沿用 MLA 但把头数从 128 减到 64;DeepSeek-V3.2-Exp 在 MLA 之上加了 DSA(DeepSeek Sparse Attention),每个 query 只选 top-2048 个 token 做 attention(config 里 index_topk: 2048)。
💬 能聊的要点
「GQA 靠减少 KV 头数省显存,MLA 靠低秩压缩:只缓存 512 维 latent 加 64 维共享 RoPE key,DeepSeek-V3 每 token 约 69 KB,比 Llama-3-70B 的 GQA 还小好几倍。」
「MLA 能不解压就算 attention,靠的是 weight absorption:把 W_UK 并进 query 侧、W_UV 并进输出投影,decode 时形态上等价于一个 KV 维度 576 的 MQA。」
「RoPE 的旋转矩阵依赖位置,会夹在 query 和 W_UK 之间、且随每个历史位置变化,破坏 absorption,所以 MLA 把位置信息拆成单独的 64 维 decoupled RoPE key。」
常见误区
❌ MLA 就是把 K/V 做低秩分解,和 LoRA(第 34 课,用低秩矩阵做微调)一样。✅ 关键不是分解本身,而是只缓存低秩中间量 + absorption 让推理无需解压 ;没有 absorption,每步都得把整个 cache 解压,得不偿失。
❌ MLA 省了显存也省了计算。✅ Decode 时每个头要在 576 维上做点积,attention 的 FLOPs 反而比 GQA 高;它是用计算换带宽,恰好适合 memory-bound 的 decode。
❌ MLA 的 KV Cache 可以像 GQA 一样按 TP 切分。✅ latent 是跨头共享的,TP 下会被复制,所以 DeepSeek 部署常用 DP-Attention。
自测(心算/口答即可)
DeepSeek-V3 的 config 里 num_key_value_heads: 128,为什么不能用它来算 KV Cache?答案 这个字段是为了兼容 HF 的通用接口,MLA 实际缓存的是 kv_lora_rank + qk_rope_head_dim = 576 维/层/token,与头数无关。
为什么 RoPE 部分的 key 要所有头共享,而不是每头一份?答案 它需要单独缓存(无法压进 latent),每头一份就是 128×64 维,会把省下的显存吃回去;共享后只需 64 维。
用 FP8 存 MLA 的 cache,DeepSeek-V3 每 token 大约多大?答案 576×61×1 B ≈ 34 KiB(忽略 scale 等元数据)。
延伸
🎬 Karpathy:无直接对应视频;前置知识见 Let's build GPT 的 attention 部分
🎬 李沐:(无直接对应内容)
📄 DeepSeek-AI 2024, DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model §2.1 (arXiv:2405.04434)
📄 DeepSeek-AI 2024, DeepSeek-V3 Technical Report §2.1.1 (arXiv:2412.19437)
📄 FlashMLA:github.com/deepseek-ai/FlashMLA
下一课 → 21 · MoE(上):稀疏 Expert、Router、Top-k
21 · MoE(上):稀疏 Expert、Router、Top-k
⏱ 10 分钟 | 前置:20 · MLA | 难度:★★☆
一句话
MoE(Mixture of Experts)把每层的一个大 FFN 换成 N 个小 FFN(Expert),再用一个 Router 为每个 token 只挑 Top-k 个来算——总参数很大、每 token 激活的参数很小 ,从而把「模型容量」和「每 token 计算量」解耦。
为什么要关心
2024 年后的前沿开源模型几乎都是 MoE:DeepSeek-V3(671B 总 / 37B 激活)、Qwen3-235B-A22B、gpt-oss、Kimi K2。
MoE 对 infra 的影响远大于对算法的影响:显存按总参数 算、计算按激活参数 算、中间还多了一轮跨卡 all-to-all 通信(每张卡都要给其他每张卡发一批数据的集合通信,类似 MapReduce 的 shuffle)。训练和推理的并行策略(EP)都因它而变。
核心直觉
1. 从 dense FFN 到 sparse MoE
回顾第 15、16 课:Transformer 每层 2/3 以上的参数在 FFN(Llama-3-8B 超过 80%,见第 26 课)。MoE 只动这一块,attention 不变。
Dense block MoE block
x ─► Attention ─► FFN ─► out x ─► Attention ─► Router ──┐
│ │ 选 top-2
┌────┬────┼────┬────┐ │
│ E1 │ E2 │ E3 │... │ E8
└─┬──┴────┴─┬──┴────┘
└── g1·E1(x) + g3·E3(x) ─► out
Expert :结构和普通 FFN(SwiGLU)一模一样,只是有 N 份独立权重。
Router (也叫 gate):一个 hidden → N 的线性层,给每个 Expert 打一个分(logit,未归一化的原始分数)。
Top-k :只保留分数最高的 k 个 Expert,用(归一化后的)分数作为权重加权求和。其余 Expert 这个 token 完全不算。
类比:一家医院有 8 个科室(Expert),分诊台(Router)看一眼病人(token)就只挂 2 个科室的号。医院总的医生数(参数)很多,但每个病人只占用 2 个科室的时间(计算)。
(类比不成立之处:分诊台是固定规则,Router 是和 Expert 一起训练出来的;而且 Expert 的「专科」也不是人为指定的,见常见误区。)
🧩 难点拆解:Router 与 Top-k 走一遍
直觉 :Router 就是一个「打分 + 取前 k 名 + 按分数分配话语权」的过程。每个 token 独立做一次,所以同一句话里的 token 可以去不同 Expert。
小例子 :4 个 Expert、top-2,某个 token 的 Router logits 为 [2.0, 0.5, 1.0, -1.0]。
取最大的 2 个:E1(2.0)和 E3(1.0),E2、E4 对这个 token 完全不计算 。
对这 2 个分数做 softmax 得到权重:E1 得 e^2 / (e^2 + e^1) ≈ 0.73,E3 得 0.27。
输出 = 0.73 · E1(x) + 0.27 · E3(x)。
为什么要乘权重而不是直接平均?因为 top-k 这个「选」的动作本身不可导,Router 的梯度只能通过这两个权重回传:如果 E1 的输出让 loss 变小,反向传播就会推高 E1 的 logit,下次更倾向于选它。(这也埋下了下一课「马太效应」的伏笔。)
一句话 :Router = 每个 token 一次「打分 → 取 top-k → softmax 加权」,未选中的 Expert 零计算,Router 靠加权系数学习怎么分。
2. 为什么「稀疏」能赢
Scaling Laws(第 18 课)告诉我们:参数越多,loss 越低。MoE 让你在同样的 per-token FLOPs 下拥有多几倍的参数。
以 Mixtral 8x7B 为例(config:32 层、hidden 4096、FFN 中间维 14336、8 个 Expert、Top-2):
每个 Expert 参数 = 3 × 4096 × 14336 ≈ 176M (SwiGLU 有 gate/up/down 三个矩阵)
全部 Expert = 176M × 8 × 32 层 ≈ 45.1B
激活 Expert = 176M × 2 × 32 层 ≈ 11.3B
+ attention/embedding 等共享部分 ≈ 1.6B
=> 总参数约 47B,激活约 13B(与 Mixtral 论文一致)
名字叫 8x7B,但总参数不是 56B:attention 和 embedding 是共享的,只有 FFN 被复制了 8 份。Mixtral 用约 13B 的计算量,效果达到或超过了 Llama 2 70B。
3. 代价是什么
Dense 70B
MoE 47B-A13B
每 token FLOPs
∝ 70B
∝ 13B ✅
显存(权重)
70B
47B(全部 Expert 都得常驻)⚠️
通信
TP/PP 常规
多了 token → Expert 的 all-to-all ⚠️
负载
均匀
Router 可能偏爱少数 Expert ⚠️(下一课)
所以 MoE 是用显存和通信换计算 。训练时这笔账非常划算(第 18 课的 6ND 用激活参数算);推理时则取决于 batch 能不能喂饱每个 Expert。
4. 小历史
2017:Shazeer et al. Outrageously Large Neural Networks: The Sparsely-Gated MoE Layer ,在 LSTM 之间插 MoE。
2020:GShard(Google)把 MoE 放进 Transformer,Top-2,并提出 Expert 按设备切分。
2021:Switch Transformer 用 Top-1,简化路由,并把 capacity factor(每个 Expert 的 token 容量上限,下一课)系统化。
2023–2024:Mixtral 8x7B(Top-2 of 8)让开源社区普遍接受 MoE;DeepSeekMoE 提出细粒度 + 共享 Expert(下一课)。
代码片段
class MoE(nn.Module):
def __init__(self, d=4096, d_ff=14336, n_expert=8, k=2):
super().__init__()
self.router = nn.Linear(d, n_expert, bias=False) # 打分器
self.experts = nn.ModuleList(SwiGLU(d, d_ff) for _ in range(n_expert))
self.k = k
def forward(self, x): # x: (n_tokens, d),已把 batch 和 seq 拍平
logits = self.router(x) # (n_tokens, n_expert)
w, idx = logits.topk(self.k, dim=-1) # 每个 token 选 k 个 Expert
w = w.softmax(dim=-1) # Mixtral:对 top-k 的 logit 再做 softmax
out = torch.zeros_like(x)
for e, expert in enumerate(self.experts):
tok, slot = (idx == e).nonzero(as_tuple=True) # 哪些 token 选了 Expert e
if tok.numel() == 0: continue
out[tok] += w[tok, slot, None] * expert(x[tok]) # 只算被选中的 token
return out
循环里按 Expert 收集 token 再批量计算:这就是「dispatch → expert compute → combine」三步,实际系统里 dispatch/combine 是跨 GPU 的 all-to-all。
x 被拍平成 token 维度:路由是逐 token 的,同一句话里不同 token 可以去不同 Expert。
生产实现不会写 Python 循环,而是用 grouped GEMM(GEMM = 矩阵乘;grouped 指一次 kernel 调用算多个不同大小的矩阵乘)。
工程视角 / 真实世界
Expert Parallelism(EP) :把不同 Expert 放到不同 GPU 上(第 30 课)。每层 MoE 前后各一次 all-to-all:先把 token 发给持有目标 Expert 的卡(dispatch),算完再发回来(combine)。这是 MoE 训练/推理的主要通信开销,DeepSeek 为此开源了 DeepEP 通信库。
推理的 batch 困境 :decode 时每个 Expert 只分到 batch × k / N 个 token。batch 小时每个 Expert 就像一个几乎没活干却要把全部权重从 HBM 读一遍的 GEMV,严重 memory-bound(GEMV = 矩阵乘向量,读一遍权重只做很少计算,瓶颈在显存带宽)。所以 MoE 大模型推理要靠大 batch + 大 EP(DeepSeek 线上部署的 decode 阶段用了跨多节点的大规模 EP)。
显存门槛 :Mixtral 47B BF16 约 94GB,一张 80GB 卡放不下;DeepSeek-V3 671B 即使 FP8 也要约 671GB,至少需要一台 8×H200 或两台 8×H100。
config.json 字段 :num_local_experts / n_routed_experts(Expert 数)、num_experts_per_tok(k)、moe_intermediate_size(每个 Expert 的中间维)。
💬 能聊的要点
「MoE 把 FFN 拆成多个 Expert,每 token 由 Router 选 top-k 个,本质是解耦总参数和每 token 计算量:Mixtral 总参 47B,但每 token 只算 13B。」
「MoE 是用显存和通信换计算:所有 Expert 必须常驻显存,EP 下每层多两次 all-to-all,所以它对 infra 的要求比 dense 高得多。」
「MoE 推理吃 batch:batch 小时每个 Expert 只分到零星几个 token,变成 memory-bound 的 GEMV,吞吐很差;这是 DeepSeek 用大规模 EP 部署的原因。」
常见误区
❌ Mixtral 8x7B = 8 个 7B 模型拼起来,共 56B。✅ 只有 FFN 被复制,attention/embedding 共享,总参约 47B。
❌ 每个 Expert 学会了一个领域(如数学 Expert、代码 Expert)。✅ Mixtral 论文分析发现路由更多与 token 的句法/表层特征 相关,而非明显的主题分工。
❌ MoE 推理一定比同总参的 dense 快。✅ 小 batch 时访存量接近总参数,并不快多少;优势在大 batch 吞吐场景。
自测(心算/口答即可)
一个 MoE 有 64 个 Expert、top-8,单个 Expert 参数和 dense FFN 的 1/8 一样大,它的每 token FFN 计算量是 dense 的多少?答案 8 × 1/8 = 1 倍,和 dense 相同,但 FFN 总参数是 dense 的 8 倍。
EP=8、每层 MoE 需要几次 all-to-all(forward)?答案 两次:dispatch(token 送到 Expert 所在卡)和 combine(结果送回)。
为什么 MoE 训练用 6ND 估算 FLOPs 时 N 用激活参数?答案 每个 token 只经过被选中的 Expert,未被选中的 Expert 对这个 token 不产生计算。
延伸
🎬 Karpathy:(无直接对应视频;FFN 基础见 Let's build GPT 的 feed-forward 部分)
🎬 李沐:(无直接对应内容)
📄 Jiang et al. 2024, Mixtral of Experts (arXiv:2401.04088)
📄 Fedus et al. 2021, Switch Transformers (arXiv:2101.03961)
📄 DeepEP:github.com/deepseek-ai/DeepEP
下一课 → 22 · MoE(下):Load Balancing、Shared Expert 与 DeepSeek-MoE
22 · MoE(下):Load Balancing、Shared Expert 与 DeepSeek-MoE
⏱ 10 分钟 | 前置:21 · MoE(上) | 难度:★★★
一句话
MoE 的核心难题是负载均衡 :Router 天然会偏爱少数 Expert。传统解法是 aux loss + capacity(容量上限,溢出就丢 token);DeepSeek 的路线是细粒度 Expert + Shared Expert + 不靠 loss、靠 bias 调节的 aux-loss-free 均衡 ,并且不丢 token。
为什么要关心
负载不均在训练中表现为 routing collapse (路由坍缩)(少数 Expert 学得越来越好、越被选中,其他 Expert 饿死);在系统里表现为 straggler (拖后腿的慢节点,和 MapReduce 里的长尾 task 一样):EP 下最忙的那张卡决定整层的耗时。
看 DeepSeek-V3 的 config 会遇到 n_shared_experts、topk_method: noaux_tc、n_group、topk_group、routed_scaling_factor……本课把这些字段讲清楚。
核心直觉
1. 问题:马太效应
训练初期某个 Expert 碰巧表现好一点 → Router 更常选它 → 它得到更多梯度、变得更好 → 更常被选。最终 N 个 Expert 里只有几个在干活,MoE 退化成小 dense 模型,还白占显存。
2. 传统方案一:Auxiliary loss
Switch Transformer / GShard 在主 loss 之外加一项均衡损失:
L_aux = α · N · Σ_i f_i · P_i
f_i = 实际被路由到 Expert i 的 token 比例(硬计数)
P_i = Router 给 Expert i 的平均概率(可导)
α = 这项 loss 的权重系数(Switch Transformer 用 0.01),N = Expert 数
大白话:如果某个 Expert 既被选得多(f 大)又被打分高(P 大),就惩罚。均匀时这项最小。
代价 :α 大了,均衡的梯度会干扰主任务,模型变差;α 小了,压不住。这是一个难调的 trade-off。
3. 传统方案二:Capacity factor 与 token dropping
为了让每张卡的计算量可预测(静态 shape,方便编译和通信),给每个 Expert 设容量上限:
capacity = capacity_factor × (tokens_in_batch × k / N)
超出容量的 token 被 drop :不经过任何 Expert,只靠 residual 连接直接传到下一层。CF=1.25 意味着允许 25% 的不均衡余量。丢 token 会损伤质量,而且推理时的 batch 构成和训练不同,行为也会不一致。后来的 dropless 实现(如 MegaBlocks 的 block-sparse kernel)和 DeepSeek-V3 都不丢 token 。
4. DeepSeekMoE:细粒度 + 共享 Expert
传统 (Mixtral): [ E1 ][ E2 ] ... [ E8 ] 选 2 个
细粒度: [e1][e2][e3][e4] ... [e32] 每个切小 4 倍,选 8 个
+ 共享 Expert: [ S ] 永远激活 + [e1]...[e32] 中选若干个
Fine-grained expert segmentation :把每个 Expert 切成 m 份更小的,同时激活数也乘 m。计算量不变,但组合数暴增(8 选 2 = 28 种;32 选 8 ≈ 1050 万种),每个 Expert 能更专精。
Shared expert isolation :留出 1–2 个每个 token 都经过的 Expert,专门学「通用知识」(语法、常见模式),让路由 Expert 不必各自重复学一遍。
DeepSeek-V3:每层 1 个 Shared + 256 个 Routed,选 8 个 ,每个 Expert 中间维只有 2048(前 3 层是普通 dense FFN,中间维 18432)。
5. DeepSeek-V3 的 aux-loss-free balancing
不再用 loss 施压,而是给每个 Expert 一个只影响选择、不影响权重 的 bias b_i:
选 top-k 时用 s_i + b_i 排序;加权求和时仍用原始 s_i。
每个训练 step 后看负载:过载的 Expert b_i -= γ,欠载的 b_i += γ(V3 里 γ = 0.001,最后 500B tokens 设为 0)。
好处:均衡压力不再通过梯度干扰主任务。V3 额外保留一个权重极小(α = 0.0001)的 sequence-wise aux loss,只防单条序列内极端不均。config 里 topk_method: noaux_tc 就是指这个;权重 e_score_correction_bias 就是 b_i。
🧩 难点拆解:Aux loss vs Bias,两种「均衡压力」怎么施加
直觉 :aux loss 像是「罚款」——在 loss 里加一项,靠梯度把 Router 往均匀方向推,但这股梯度和主任务的梯度混在同一批参数上,会互相拉扯。bias 像是「调度器」——不碰梯度,直接在「排队选人」时给忙的 Expert 减分、闲的加分,类似按队列长度调权重的负载均衡器。(类比不成立处:bias 不看 token 内容,只看全局负载统计,所以它纠正的是「整体偏科」,不是某个 token 分错了。)
小例子 1:aux loss 的数值 。4 个 Expert、top-1、α 先不管:
完全均匀:f = P = [0.25]×4,N·Σ f·P = 4 × 4 × 0.0625 = 1.0(这是最小值)。
严重偏科:f = [0.7, 0.1, 0.1, 0.1],P = [0.6, 0.13, 0.13, 0.14],4 × (0.42 + 0.013 + 0.013 + 0.014) ≈ 1.84。
这 0.84 的差值乘上 α 进入总 loss,梯度通过 P 去压低 E1 的分数——同一个 Router 权重既要为「预测准」服务,又要为「分得匀」服务。
小例子 2:bias 的调节 。某类 token 的亲和度总是 s = [0.9, 0.8, 0.3, 0.2],top-1 永远选 E1,E1 过载。设 γ = 0.05(示意用,V3 实际是 0.001):
step
bias
s + b(用于选择)
选中
输出权重(用原始 s)
0
[0, 0, 0, 0]
[0.90, 0.80, 0.30, 0.20]
E1
0.9
1
[-0.05, +0.05, +0.05, +0.05]
[0.85, 0.85, 0.35, 0.25]
平局
—
2
[-0.10, +0.10, +0.10, +0.10]
[0.80, 0.90, 0.40, 0.30]
E2
0.8 (不是 0.9)
bias 只把「这类 token」的一部分挪给了第二合适的 E2;被选中后 E2 的贡献仍按它自己的真实分数 0.8 计算,模型输出没有被 bias 扭曲。(示意简化:V3 实际选 8 个,再在这 8 个的原始分数之间归一化。)
一句话 :aux loss 通过梯度「劝」Router 均衡,会干扰主任务;bias 在梯度之外直接「调度」,只改谁被选、不改怎么加权。
6. 为通信设计路由:node-limited routing
EP 跨节点时,IB 带宽约为 NVLink 的 1/3。V3 把 256 个 Expert 按节点分成 8 组(n_group: 8),每个 token 最多发往 4 个节点 (topk_group: 4),先选节点、再在这些节点里选 8 个 Expert。这是「算法为 infra 让步」的典型例子。
代码片段
# DeepSeek-V3 风格路由:sigmoid 打分 + bias 只用于选择 + 训练中在线调 bias
def route(x, W_router, bias, k=8, scale=2.5):
s = torch.sigmoid(x @ W_router) # (T, 256) 亲和度;sigmoid 让每个 Expert 独立打 0~1 分,不像 softmax 那样互相挤占
_, idx = (s + bias).topk(k, dim=-1) # ① 选择:带 bias
w = s.gather(-1, idx) # ② 权重:不带 bias 的原始分数
w = w / w.sum(-1, keepdim=True) * scale # 归一化后乘 routed_scaling_factor
return idx, w
@torch.no_grad()
def update_bias(bias, idx, n_expert=256, gamma=1e-3):
load = torch.bincount(idx.flatten(), minlength=n_expert).float()
bias -= gamma * torch.sign(load - load.mean()) # 过载的降、欠载的升
bias 不参与反向传播,是纯粹的「控制器」,类似系统里根据队列长度调权重的负载均衡器。
选择和加权解耦:bias 改变「谁被选中」,但不扭曲被选中者的输出权重。
真实实现还要先按 n_group/topk_group 做节点级筛选,这里省略。
工程视角 / 真实世界
各家 MoE 设计对照(Expert 数来自官方 config.json,均衡方式来自技术报告/config 字段):
模型
Routed Expert
激活
Shared
均衡方式
Mixtral 8x7B
8
2
0
aux loss
DeepSeek-V2
160
6
2
aux loss(多级)
DeepSeek-V3 / R1
256
8
1
bias(aux-loss-free)+ 极小 seq aux
Qwen3-235B-A22B
128
8
0
global-batch aux loss
gpt-oss-120b
128
4
0
官方未详述
Kimi K2
384
8
1
bias(noaux_tc,同 V3)
Qwen3-Next-80B-A3B
512
10
1
aux loss
趋势很清晰:Expert 越来越多、越来越小,激活数 8 左右 。Qwen3 技术报告明确说去掉了 Shared Expert,并使用 global-batch(而非 micro-batch)级别的均衡 loss 来鼓励 Expert 专业化——说明 Shared Expert 不是必选项。
推理侧的负载均衡 :训练均衡不代表线上均衡(不同业务流量的路由分布不同)。DeepSeek 开源了 EPLB(Expert Parallelism Load Balancer),把热门 Expert 复制 多份(redundant experts)分到不同卡上。
Expert 很细(中间维 2048 甚至 512)意味着单个 GEMM 很小,必须用 grouped GEMM 和通信-计算重叠才能跑满 GPU。
💬 能聊的要点
「传统 MoE 靠 aux loss 做均衡,但 loss 系数大了伤效果、小了压不住;DeepSeek-V3 改成给每个 Expert 加一个只影响 top-k 选择、不影响权重的 bias,按负载在线加减,基本不干扰主任务。」
「DeepSeekMoE 的两个设计:细粒度 Expert 让组合更灵活、Expert 更专精;Shared Expert 承担通用知识,减少路由 Expert 之间的冗余。V3 是 1 个 Shared + 256 选 8。」
「负载均衡本质上是系统问题:EP 下最忙的卡决定整层耗时,所以有 capacity factor、node-limited routing,以及推理时的冗余 Expert(EPLB)。」
常见误区
❌ Aux-loss-free 就是完全没有均衡 loss。✅ V3 仍保留一个系数 0.0001 的 sequence-wise balance loss 防极端情况,主力是 bias。
❌ 被 drop 的 token 就丢失了。✅ 它只是跳过这层 MoE,通过 residual 继续前进;但确实损失了这层的计算。
❌ Shared Expert 是 MoE 标配。✅ Qwen3 MoE 明确去掉了它,gpt-oss 也没有;这是设计选择。
自测(心算/口答即可)
1024 个 token、64 个 Expert、top-2、capacity factor 1.0,每个 Expert 容量多少?答案 1.0 × 1024 × 2 / 64 = 32 个 token。
为什么 bias 只用于选择、不用于加权?答案 如果 bias 进入加权,它会直接改变模型输出、影响梯度,就又变成了一种干扰主任务的均衡手段;只影响选择则只改变「分工」,不扭曲被选中 Expert 的贡献。
DeepSeek-V3 为什么限制每个 token 最多发往 4 个节点?答案 跨节点 IB 带宽远低于节点内 NVLink,限制节点数可以控制 all-to-all 的跨节点流量,让通信能和计算重叠。
延伸
🎬 Karpathy:(无直接对应视频)
🎬 李沐:(无直接对应内容)
📄 Dai et al. 2024, DeepSeekMoE: Towards Ultimate Expert Specialization in Mixture-of-Experts Language Models (arXiv:2401.06066)
📄 Wang et al. 2024, Auxiliary-Loss-Free Load Balancing Strategy for Mixture-of-Experts (arXiv:2408.15664)
📄 DeepSeek-V3 Technical Report §2.1.2 (arXiv:2412.19437);EPLB:github.com/deepseek-ai/EPLB
下一课 → 23 · 长上下文:RoPE 外推、Sliding Window 与 Attention Sink
23 · 长上下文:RoPE 外推(PI / NTK / YaRN)、Sliding Window、Attention Sink
⏱ 10 分钟 | 前置:22 · MoE(下) | 难度:★★★
一句话
长上下文要解决两件事:位置编码没见过的长度 (用 PI / NTK-aware / YaRN 把 RoPE「拉伸」到新长度),以及 attention 的 O(L²) 计算与 O(L) KV Cache (用 Sliding Window、local/global 交替来限制);Attention Sink 则解释了为什么只保留窗口会崩、以及怎么修。
为什么要关心
模型卡上写「128K context」,几乎都不是从头按 128K 训的,而是短上下文预训练 + 位置插值 + 少量长文本继续训练 。config.json 里的 rope_scaling 字段就是这件事的痕迹。
对 infra 来说,长上下文意味着 KV Cache 和 prefill 计算暴涨。Sliding Window 这种架构选择直接改变 KV Cache 的管理方式(vLLM 需要为 local 层和 global 层分别管理 cache)。
核心直觉
1. 问题根源:RoPE 的低频维度「没转过那么多圈」
回顾第 13 课:RoPE 把 head_dim 拆成若干二维对,第 i 对以频率 θ_i = base^(-2i/d) 旋转。
维度对: 高频(i 小) 低频(i 大)
波长: ~6 个 token 转一圈 ... 几万 token 才转一圈
训练 4K 时: 转了成百上千圈,什么角度都见过 只转了不到一圈,大角度从没见过
推理超过训练长度时,高频维度没问题 (角度周期性重复),低频维度出现从未见过的角度 → attention 分数失常 → perplexity(困惑度,e^loss,越低越好)爆炸。所有外推方法都在处理这个「低频维度 OOD」(out-of-distribution,输入落在训练时没见过的范围)的问题。
2. 三种 RoPE scaling,一张表看清
方法
做法
优点
代价
PI (Position Interpolation,Meta 2023)
位置整体压缩:m → m / s(s = 新长度/旧长度)
简单,所有角度都落回训练范围
高频维度也被压缩,相邻 token 难以区分;需要微调
NTK-aware (社区 2023)
不改位置,改 base:base' = base · s^(d/(d-2))
高频几乎不变、低频被插值;不微调也能用一些
各维度处理不够精细
YaRN (Peng et al. 2023)
按维度分段:高频不动、低频做 PI、中间线性过渡;再加温度修正:q、k 各乘 0.1·ln(s)+1(等价于 attention logits 乘它的平方)
效果最好、微调数据少
多了超参(beta_fast、beta_slow)
类比:一把只刻到 4K 的尺子,PI 是把刻度整体缩小(精度下降),NTK/YaRN 是只把粗刻度拉长、细刻度保持原样 。
另一条相关的路:直接把 base 调大 再训练。Llama 3 用 rope_theta: 500000,Qwen3 用 1000000(原始 Transformer/Llama 2 是 10000),让低频维度转得更慢,从一开始就为长上下文留余量。
🧩 难点拆解:RoPE 频率与三种「拉伸」
直觉 :把 RoPE 的 64 个维度对(head_dim 128 ÷ 2)想成 64 根转速不同的表针 :第 0 根像秒针,每个 token 转 1 弧度;最后一根像「年针」,几万个 token 才转一圈。一个位置 = 所有表针的读数组合。模型在训练里学会了「读表」,但只见过训练长度内各表针能到达的角度。
小例子 (head_dim 128,base 10000,训练长度 4K,要扩到 32K 即 s = 8):
维度对
频率 θ(弧度/token)
波长(token/圈)
4K 内转了几圈
PI 后
NTK 后
YaRN 后
i=0(秒针)
1
6.3
652
慢 8 倍
不变
不变
i=32(中间)
0.01
628
6.5
慢 8 倍
慢约 2.9 倍
慢约 3.6 倍
i=63(年针)
0.000115
约 5.4 万
0.075
慢 8 倍
慢 8 倍
慢 8 倍
怎么读这张表:
i=0 :4K 内转了 652 圈,所有角度都见过。推到 32K 只是多转几圈,没有新角度——不需要改。但 PI 把它也放慢 8 倍,相邻两个 token 的角度差从 1 弧度变成 0.125,「这是前一个词还是前两个词」变难分辨。
i=63 :4K 内只转了 7.5% 圈。推到 32K 时要转到 60%,这些角度模型从没见过 → OOD。三种方法都把它放慢 8 倍,32K 时又回到熟悉的 7.5% 范围。
中间维度 :NTK(换 base)按指数平滑过渡;YaRN 按「4K 内转了几圈」分段:>32 圈不动、<1 圈完全插值、之间线性混合(6.5 圈 → 混合比 0.18)。
一句话 :外推失败只发生在「训练长度内没转满一圈」的慢表针上;好的方法只放慢慢表针、不碰快表针。
3. 真实 config 里的 rope_scaling
DeepSeek-V3: {"type": "yarn", "factor": 40, "original_max_position_embeddings": 4096,
"beta_fast": 32, "beta_slow": 1} → 4K × 40 = 160K
gpt-oss: {"rope_type": "yarn", "factor": 32, "original_max_position_embeddings": 4096}
→ 4K × 32 = 128K
Llama 3.1: {"rope_type": "llama3", "factor": 8, "original_max_position_embeddings": 8192,
"low_freq_factor": 1, "high_freq_factor": 4} → 8K × 8 = 128K(思路类似 YaRN 的分段)
Qwen3: 默认 null(原生 32K),官方建议需要时手动加 YaRN factor 4 → 约 128K
训练侧:Llama 3 405B 分 6 个阶段 从 8K 逐步扩到 128K,用了约 800B tokens;DeepSeek-V3 用两个各 1000 步的阶段从 4K → 32K → 128K。
4. Sliding Window Attention(SWA):限制「看多远」
每个 token 只 attend 最近 W 个 token:
full causal: sliding window (W=3):
■ ■
■ ■ ■ ■
■ ■ ■ ■ ■ ■
■ ■ ■ ■ · ■ ■ ■
■ ■ ■ ■ ■ · · ■ ■ ■
计算从 O(L²) 变成 O(L·W),KV Cache 上限为 W(可做成 rolling buffer,即环形缓冲区,新 token 覆盖最老的)。
层叠后感受野(一个 token 最终能间接受到多远 token 的影响)会扩大:第 k 层理论上能间接看到 k×W 远。Mistral 7B:W=4096、32 层,理论感受野约 131K。
纯 SWA 的远距离信息传递太弱,于是出现 local/global 交替 :
Gemma 2:local(4096) 与 global 层 1:1 交替。
Gemma 3:5 个 local(W=1024): 1 个 global ,128K 上下文下 KV Cache 大幅缩小。
gpt-oss:sliding(W=128)与 full 层 1:1 交替。
5. Attention Sink:为什么窗口不能随便滑
StreamingLLM(Xiao et al. 2023)发现:模型会把大量注意力分给开头几个 token ,不管它们是什么内容。原因是 softmax 必须归一化到 1,当一个头「这一步不需要看任何东西」时,需要一个垃圾桶来倾倒注意力,最先出现、对所有后续 token 可见的开头 token 就成了这个桶。
后果:用纯滑窗把开头 token 挤出 cache,模型立刻崩溃。修复:永远保留开头 4 个 token + 最近窗口 ,就能稳定生成数百万 token。
gpt-oss 把这个思想做进了架构:每个 head 有一个可学习的 sink logit ,加在 softmax 的分母里(相当于多了一个不存在的「虚拟 token」来吸收注意力),允许「这一步谁都不看」,不再需要占用真实 token。
代码片段
# 三种 RoPE 频率改法(d = head_dim,s = 扩展倍数)
def inv_freq(d, base=10000.0):
return base ** (-torch.arange(0, d, 2) / d) # θ_i,i 越大频率越低
def pi(pos, d, s): # PI:位置整体除以 s
return (pos / s)[:, None] * inv_freq(d)
def ntk_aware(pos, d, s): # NTK-aware:放大 base,低频被拉伸、高频几乎不动
return pos[:, None] * inv_freq(d, base=10000.0 * s ** (d / (d - 2)))
def yarn(pos, d, s, L_orig=4096, beta_fast=32, beta_slow=1):
f = inv_freq(d)
rotations = L_orig * f / (2 * math.pi) # 训练长度内每个维度转了几圈
ramp = ((rotations - beta_slow) / (beta_fast - beta_slow)).clamp(0, 1)
f_new = f / s * (1 - ramp) + f * ramp # 转得多(高频)保持,转得少(低频)做 PI
return pos[:, None] * f_new # 另外 q、k 各乘 0.1*ln(s)+1(温度修正)
关键判据是「训练长度内转了几圈」:转超过 beta_fast 圈的维度早已见过所有角度,不动;不到 beta_slow 圈的维度需要完整插值。
NTK-aware 的指数 d/(d-2) 保证最低频维度恰好被拉伸 s 倍,最高频维度几乎不变。
这些只改变 RoPE 的角度,不改模型权重 ,所以通常配合少量长文本继续训练。
工程视角 / 真实世界
KV Cache 账本 :Gemma 3 的 5:1 设计里,local 层每层最多缓存 1024 个 token,只有 1/6 的层要缓存全部 128K。gpt-oss 一半层只缓存 128 个 token。vLLM 为此引入了混合 KV Cache 管理(不同层不同 cache 策略)。
YaRN 的温度 :长文本里每个 query 要在更多 token 间分配注意力,softmax 会变「平」,温度把 logits 放大、让分布重新变尖(s=40 时 q、k 各乘 0.1·ln40+1 ≈ 1.37,logits 相当于乘 1.37² ≈ 1.87)。DeepSeek config 里的 mscale / mscale_all_dim 就是这个温度系数的实现细节;推理框架加载时如果漏掉,长文本效果会明显下降。
静态 vs 动态 YaRN :vLLM 等框架对 YaRN 是静态应用的(不论输入多长都用同一个 factor),Qwen 官方因此提示只在确实需要长上下文时才开启,否则短文本效果可能略受影响。
超长上下文训练还需要 Context Parallel / Ring Attention 把一条序列切到多卡(第 30 课)。
💬 能聊的要点
「RoPE 外推失败的根源是低频维度在训练长度内没转满一圈,推理时遇到没见过的角度;PI 是整体压缩位置,NTK-aware 改 base,YaRN 按维度分段只插值低频并加温度修正,是目前主流。」
「看 config 的 rope_scaling 就知道模型怎么扩的上下文:DeepSeek-V3 是 YaRN 4K×40,gpt-oss 是 YaRN 4K×32,Llama 3.1 是自己的 llama3 分段缩放 8K×8。」
「Sliding window 把 KV Cache 限定在窗口内,但需要保留 attention sink——开头几个 token 充当 softmax 的垃圾桶;gpt-oss 直接给每个头加了可学习的 sink logit。」
常见误区
❌ max_position_embeddings 就是模型真实能用的上下文。✅ 它只是一个上限配置;要结合 rope_scaling 和官方说明判断,比如 Qwen3 原生 32K、开 YaRN 才到 128K。
❌ SWA 模型只能看到窗口内的内容。✅ 多层叠加后感受野会扩大,加上 global 层交替,可以处理远距离依赖(但精度不如全 attention)。
❌ Attention sink 是 bug。✅ 它是 softmax 归一化约束下模型学出来的合理行为;工程上要保留它,或像 gpt-oss 那样显式给它一个位置。
自测(心算/口答即可)
原始长度 4K、要扩到 32K,PI 的缩放因子是多少?NTK-aware 对高频维度做了什么?答案 s = 8,所有位置除以 8;NTK-aware 对高频维度几乎不改变,主要拉伸低频维度。
Mistral 7B 32 层、W=4096,为什么理论感受野约 131K?答案 每一层能把信息向前传 W 个位置,32 层叠加 32 × 4096 ≈ 131K。
用纯滑窗做无限长流式生成,为什么要额外保留前 4 个 token?答案 它们是 attention sink,承接大量「无处可去」的注意力;移出 cache 后注意力分布剧变,模型输出崩溃。
延伸
🎬 Karpathy:(无直接对应视频)
🎬 李沐:(无直接对应内容;RoPE 前置见第 13 课)
📄 Chen et al. 2023, Extending Context Window of Large Language Models via Positional Interpolation (arXiv:2306.15595)
📄 Peng et al. 2023, YaRN: Efficient Context Window Extension of Large Language Models (arXiv:2309.00071)
📄 Xiao et al. 2023, Efficient Streaming Language Models with Attention Sinks (arXiv:2309.17453)
下一课 → 24 · Linear Attention / SSM / Hybrid:Mamba、Gated DeltaNet 与混合架构
24 · Linear Attention / SSM / Hybrid:Mamba、Gated DeltaNet 与混合架构
⏱ 10 分钟 | 前置:23 · 长上下文 | 难度:★★★
一句话
Linear Attention 和 SSM(State Space Model,状态空间模型,如 Mamba)把「随长度增长的 KV Cache」换成「固定大小的状态矩阵 」,像 RNN 一样逐 token 更新,推理每步 O(1);代价是记忆有损、精确召回变差,所以 2025 年的主流做法是 Hybrid :大部分层用线性层(如 Gated DeltaNet),每隔几层插一层 full attention。
为什么要关心
Softmax attention 的 prefill 是 O(L²),KV Cache 是 O(L)。上下文到 1M 时,这两项都难以承受。
Qwen3-Next、Kimi Linear、MiniMax-01、Jamba 都是 hybrid 模型。它们的推理引擎要同时管理 KV Cache 和定长 state (vLLM/SGLang 里叫 Mamba cache / linear attention state),prefix caching 等优化也要重新设计。
核心直觉
1. Linear attention:去掉 softmax,就能换个乘法顺序
softmax attention: o = softmax(Q Kᵀ) V 必须先算 L×L 矩阵,不能换序
linear attention: o = φ(Q) (φ(K)ᵀ V) 先算 K ᵀV —— 一个 d×d 的矩阵,与 L 无关
写成递推(第 t 步)就是一个 RNN:
S_t = S_{t-1} + v_t k_tᵀ # state:d×d 的「联想记忆」,把 (k, v) 对写进去
o_t = S_t q_t # 读:用 q 去查这个记忆
类比:softmax attention 是把每页笔记都留着 (KV Cache),每次查询翻遍所有页;linear attention 是只维护一张固定大小的汇总表 ,每来一页就把它「叠加」进去。查得快、占地小,但叠多了会糊。
🧩 难点拆解:为什么去掉 softmax 就变成 RNN、变成 O(n)
直觉 :矩阵乘法满足结合律,(QKᵀ)V = Q(KᵀV)。softmax 夹在 QKᵀ 外面,必须先把 L×L 的分数矩阵算完整、逐行归一化,括号挪不动。换成一个逐元素的函数 φ(例如 elu(x)+1,只是让分数保持为正),括号就能挪了。
看 shape (L = 序列长度,d = head_dim):
算法
中间结果
计算量
L = 100K,d = 128
(QKᵀ)V
L×L 分数矩阵
L²·d
1.3×10^12
Q(KᵀV)
d×d 矩阵
L·d²
1.6×10^9(少约 800 倍)
为什么变成 RNN :因果模型里 token t 只能看 1..t,所以不能用全局的 KᵀV,而要用「截至 t 的前缀和」S_t = Σ_{j≤t} v_j k_jᵀ。前缀和天然可以递推:S_t = S_{t-1} + v_t k_tᵀ——这就是一个 state 为 d×d 矩阵的 RNN。每来一个 token 做一次 O(d²) 的更新,总共 O(L·d²),对 L 是线性的;decode 时不用回看历史,所以每步 O(1)。
小例子 (d = 2):写入两对 k1=[1,0]→v1=[5,7]、k2=[0,1]→v2=[3,1]:S = v1k1ᵀ + v2k2ᵀ = [[5,3],[7,1]]。用 q=[1,0] 去查:S q = [5,7],正好取回 v1。state 就是一个「key → value」的联想记忆 ,只是用一个矩阵存。
一句话 :去掉 softmax → 可以先算 KᵀV → 因果版本是前缀和 → 前缀和就是 RNN 的 state 更新,所以 O(L) 训练、O(1) 每步推理。
2. 问题:只写不擦,记忆会糊
朴素 linear attention 的 state 只做加法,信息越叠越多、互相干扰,效果明显差于 softmax attention。后续工作的核心都是给 state 加上「遗忘」和「改写」的能力 :
模型
state 更新
直觉
Linear Attention (2020)
S = S + v kᵀ
只写不擦
Mamba / Mamba-2 (2023/24)
S = α_t·S + v kᵀ(α_t 依赖输入)
gate :按内容决定遗忘多少(selective)
DeltaNet (2021 提出,2024 并行化)
S = S(I − β k kᵀ) + β v kᵀ
delta rule :先擦掉 k 处的旧值,再写新值
Gated DeltaNet (2024/25)
S = α_t·S(I − β k kᵀ) + β v kᵀ
gate + delta 两者结合
Mamba (Gu & Dao)源自 SSM(State Space Model)路线:连续系统离散化而来,关键创新是让参数依赖输入 (selective),像 LSTM 的门。Mamba-2 进一步证明 SSM 与 linear attention 在数学上是同一族(SSD)。
Delta rule 来自经典的在线学习:state 把 k 映射到 v,新来一对 (k, v) 时,先查出旧值 S k,只写入差值——相当于覆盖更新 而不是累加。
Gated DeltaNet 同时有快速整体遗忘(gate)和精准改写(delta),是目前 Qwen3-Next 的选择;Kimi Linear 的 KDA(Kimi Delta Attention)是它的细粒度 gate 变体。
🧩 难点拆解:Delta rule——从「累加」到「覆盖」
直觉 :朴素版本像一个只会 += 的 KV 存储,同一个 key 写两次就把两个 value 加在一起了。Delta rule 把它变成「先读旧值、再写差值」,效果上接近 put(k, v) 覆盖。
小例子 (接上面的 S = [[5,3],[7,1]]):现在要把 key [1,0] 的值更新为 v3 = [9,9]。
朴素:S += v3·k3ᵀ → 再用 [1,0] 查得 [14,16],新旧混在一起。
Delta(β = 1):v_old = S·k3 = [5,7],S += (v3 − v_old)·k3ᵀ = [4,2]·k3ᵀ → 再查得 [9,9],干净覆盖,k2 的记忆不受影响。
β ∈ (0,1) 时是「部分覆盖」:β = 0.5 查得 [7,8],往新值走一半——这就是一步在线梯度下降,β 相当于学习率。
现实中 key 不会两两正交,d 维 state 最多存大约 d 对互不干扰的 (k, v),所以仍然需要 gate α 整体遗忘旧内容来腾地方。
一句话 :delta rule 让定长记忆可以「改写」而不只是「叠加」,gate 让它可以「清空」;Gated DeltaNet 两者都有。
3. 为什么还需要 full attention:Hybrid
定长 state 的本质是有损压缩 。需要「从上下文里一字不差地找回某个东西」(needle-in-a-haystack——在长文里藏一句话让模型找出来、长文精确引用、复制)时,纯线性模型明显吃亏。Hybrid 的思路:线性层负责大部分「理解与汇总」,少数 full attention 层负责「精确检索」。
Qwen3-Next (48 层): [GDN][GDN][GDN][Attn] × 12 3:1
Kimi Linear (27 层): [KDA][KDA][KDA][MLA] ... 约 3:1
MiniMax-01 (80 层): [Lightning]×7 [Softmax] × 10 7:1
Jamba v0.1 (32 层): [Mamba]×7 [Attn] ... × 4 每 8 层 1 层 attention(外加每 2 层一个 MoE)
以上比例都来自官方 config.json(full_attention_interval: 4、full_attn_layers、attn_type_list、attn_layer_period: 8)。
4. 账本:Qwen3-Next-80B-A3B 的「cache」
12 个 full attention 层:2 个 KV head × head_dim 256 → 每 token 2 × 12 × 2 × 256 × 2B = 24 KiB(对比 Llama-3-8B 的 128 KiB)。
36 个 Gated DeltaNet 层:每层 32 个 value head × 128 × 128 的 state,与长度无关 ;按 2 字节算每条序列固定约 36 MiB。
短序列时 36 MiB 的 state 反而比 KV Cache 大;序列越长,hybrid 的优势越明显。这也是这些模型主打 256K–1M 上下文的原因。
5. 反例:MiniMax-M2 回到了 full attention
MiniMax-01/M1 是 7:1 hybrid,但 2025 年 10 月的 MiniMax-M2 在 config 里所有层都是 full attention(attn_type_list 全为 1)。团队在博客《Why Did M2 End Up as a Full Attention Model?》里给出的主要原因:高效 attention 在复杂推理、agent 等任务上的缺陷在常规评测里不易暴露;线性 attention 对数值精度更敏感,和推理时常用的低精度 KV Cache / state 存储冲突;配套的 kernel 与推理基础设施(prefix cache、投机解码等)还不成熟。Hybrid 还是一个开放问题,不是定论。
代码片段
# Gated DeltaNet 的递推形式(单头,推理时逐 token;训练时用 chunk 并行算法)
def gated_deltanet_step(S, q, k, v, alpha, beta):
# S: (d_v, d_k) 定长 state;alpha∈(0,1) 遗忘门;beta∈(0,1) 写入强度
k = k / k.norm() # key 归一化,delta rule 需要
v_old = S @ k # 当前记忆里 k 对应的旧值
S = alpha * S # ① gate:整体衰减(Mamba-2 式遗忘)
S = S + beta * torch.outer(v - alpha * v_old, k) # ② delta:用新值覆盖旧值
o = S @ q # 读出
return S, o
# 对比:softmax attention 的 decode 要读完整 KV Cache,每步 O(T)
# 这里每步只读写一个 d_v×d_k 的矩阵,O(1),与已生成多少 token 无关
v - alpha * v_old 是 delta rule 的精髓:只写入「新值与旧记忆的差」,等价于把 S 当成 k→v 的映射做一步在线梯度下降。
训练时不能真的逐 token 循环(太慢),而是用 chunkwise 并行算法把序列切块,在块内用矩阵乘;这类 kernel 集中在开源库 flash-linear-attention(fla)里。
真实实现还有一个短卷积(config 的 linear_conv_kernel_dim: 4)和输出 gate。
工程视角 / 真实世界
推理引擎改造 :vLLM/SGLang 要为 hybrid 模型同时管理 PagedAttention 的 KV block 和每条请求的定长 state。Prefix caching 更麻烦:KV Cache 可以按 block 复用,但 state 只能在特定位置做快照(checkpoint)后复用。
投机解码的回滚 :KV Cache 回滚只需截断;定长 state 被草稿 token 更新后无法「减回去」,需要保存中间 state。
Qwen3-Next 的其他设计 :full attention 层用了 Gated Attention (给 attention 输出加 sigmoid gate,论文称可缓解 attention sink 与数值问题)、partial RoPE(partial_rotary_factor: 0.25)、512 个 Expert 选 10 + 1 Shared、原生 MTP。
2020 年之后的线性 attention 路线大起大落,到 2025 年才在大规模模型上真正落地 ;面试时说清「为什么纯线性不行、为什么 hybrid 可以、为什么还有人退回 full attention」比背公式更重要。
💬 能聊的要点
「Linear attention 去掉 softmax 后可以先算 KᵀV,得到一个 d×d 的定长 state,推理像 RNN 一样每步 O(1),彻底消除随长度增长的 KV Cache。」
「纯线性模型的问题是定长 state 有损,精确召回差;Mamba 引入输入相关的遗忘门,DeltaNet 引入先擦后写的 delta rule,Gated DeltaNet 两者兼有。」
「2025 年主流是 hybrid:Qwen3-Next 是 3 层 Gated DeltaNet 配 1 层 gated attention,MiniMax-01 是 7:1;但 MiniMax-M2 又退回了 full attention,说明这条路还没有定论。」
常见误区
❌ Linear attention 就是「近似」softmax attention。✅ 它是另一种序列混合机制(更像带门控的 RNN),能力边界不同,不是简单的近似。
❌ Hybrid 模型没有 KV Cache。✅ 那几层 full attention 仍有 KV Cache,只是层数少;线性层则是定长 state。
❌ 线性层训练也是逐 token 递推,所以训练慢。✅ 训练用 chunkwise 并行形式,可以高效利用 Tensor Core(GPU 上专做矩阵乘的单元,第 35 课);递推形式主要用于 decode。
自测(心算/口答即可)
一个纯 linear attention 层,head_dim 128,32 个头,处理 1M token 后 state 多大?答案 32 × 128 × 128 = 524,288 个元素,与 token 数无关,BF16 约 1 MiB。
Delta rule 相比朴素 linear attention 的「只加」更新,解决了什么问题?答案 同一个 key 反复写入时,朴素版本会叠加产生干扰;delta rule 先读出旧值、只写差值,实现覆盖更新,记忆更干净。
为什么 hybrid 模型在短对话场景下显存优势不明显?答案 线性层的 state 是固定开销(Qwen3-Next 约 36 MiB/序列),短序列时 KV Cache 本来就小,定长 state 可能比省下的 KV Cache 还大。
延伸
🎬 Karpathy:(无直接对应视频;makemore 仓库里的 RNN/GRU 实现可帮助理解递推形式)
🎬 李沐:《动手学深度学习》「循环神经网络」与「现代循环神经网络(GRU/LSTM)」章节——理解「门控 state」的前置
📄 Katharopoulos et al. 2020, Transformers are RNNs (arXiv:2006.16236);Gu & Dao 2023, Mamba (arXiv:2312.00752)
📄 Yang et al. 2024, Gated Delta Networks: Improving Mamba2 with Delta Rule (arXiv:2412.06464)
📄 MiniMax-01 (arXiv:2501.08313);Kimi Linear (arXiv:2510.26692);Qwen3-Next 官方博客(qwen.ai)
下一课 → 25 · MTP 与其他训练技巧:QK-Norm、Soft-capping、Tied Embedding
25 · MTP 与其他训练技巧:QK-Norm、Soft-capping、Tied Embedding
⏱ 10 分钟 | 前置:24 · Linear Attention / SSM / Hybrid | 难度:★★☆
一句话
现代 LLM 在「标准 Transformer」之上叠了一堆小改动:MTP (一次多预测几个 token,训练信号更密、推理可做投机解码)、QK-Norm / Soft-capping / QK-Clip (防止 attention logits 爆炸导致训练崩溃)、Tied Embedding (小模型省参数)——每一个都对应 config.json 里的一个字段。
为什么要关心
这些改动单看都不大,但读模型代码、写推理引擎的 model 文件时处处会碰到:漏掉一个 q_norm、一个 softcap,模型输出就是乱码。
大规模训练最怕 loss spike (loss 突然飙升,甚至发散),一次重启就是几十万 GPU 小时。本课的「稳定性技巧」都是为了这个。
MTP 同时是训练技巧和推理加速手段,是连接 Part 3 和第 42 课(投机解码)的桥。
核心直觉
1. MTP(Multi-Token Prediction):每个位置多学几步
标准 LM 每个位置只预测下一个 token。MTP 让模型在位置 t 同时预测 t+1、t+2……
两种做法:
Meta 2024(并行头) DeepSeek-V3(串行模块)
┌─ head1 → t+1 主模型 ──h_t──► 预测 t+1
trunk h_t ┼─ head2 → t+2 │
└─ head3 → t+3 ▼
MTP 模块: [RMSNorm(h_t) ; RMSNorm(Emb(tok_{t+1}))]
→ Linear → 1 个 Transformer block → 预测 t+2
Meta 的并行头 (Gloeckle et al. 2024):共享主干,n 个独立输出头,各自预测未来第 k 个 token。
DeepSeek-V3 的串行模块 :MTP 模块拿到主模型的 hidden state,再拼上真实的下一个 token 的 embedding ,经过一个 Transformer block 预测再下一个。保留了完整的因果链。V3 只多预测 1 个 token(config 里 num_nextn_predict_layers: 1),embedding 与输出头和主模型共享。
为什么有用:
训练信号更密 :每个位置多一个监督目标,逼模型「提前规划」。V3 的 MTP loss 权重 λ 在前 10T tokens 为 0.3,之后 4.8T 为 0.1。
推理白送一个 draft model (草稿模型:先便宜地猜几个 token,再由主模型一次性验证):推理时可以直接丢掉 MTP 模块(主模型独立可用),也可以用它做投机解码 :MTP 猜下一个 token,主模型一次验证。V3 报告第二个 token 的接受率在 85%–90%,生成速度约 1.8 倍 TPS 。
🧩 难点拆解:MTP 在训练和推理里各干了什么
直觉 :MTP 模块是一个挂在主模型旁边的「小实习生」。训练时它做额外的练习题(预测再下一个 token),逼主模型的 hidden state 里多装一点「后面会发生什么」的信息;推理时它可以当草稿员,主模型负责审核。
小例子 :句子 the cat sat on the mat,看位置 t = cat。
训练 :主模型用 h_t 预测 t+1 = sat,得 loss L_main;MTP 模块拿 h_t + 真实的 Emb(sat),预测 t+2 = on,得 L_MTP。总 loss = L_main + λ·L_MTP(V3 前期 λ = 0.3)。每个位置从 1 道题变成 2 道题。
推理(投机解码,第 42 课) :
一次 forward:主模型输出 sat;MTP 用 h_t + Emb(sat)(这次是主模型刚生成的)猜出 on。
下一次 forward 把 sat, on 一起喂进去:主模型在 sat 位置算出的下一个 token 如果也是 on,就「接受」——一次 forward 拿到 2 个 token;否则丢掉 on,只拿到主模型自己算的那个。
接受率 85%–90% ⇒ 平均每次 forward 产出约 1.85–1.9 个 token,扣掉 MTP 自身开销,实测约 1.8 倍 TPS(tokens per second)。
一句话 :训练时 MTP = 多一道「预测再下一个」的练习题;推理时 MTP = 自带的 draft model,猜对就一次拿两个 token。
MiniMax-M2(config num_mtp_modules: 3)、Qwen3-Next 也内置了 MTP;Kimi K2 的 config 则是 num_nextn_predict_layers: 0——不是必选项。
2. Attention logits 爆炸:三种止血方法
训练大模型时常见的崩溃模式:某些头的 q·k 越来越大 → softmax 变成 one-hot(几乎全部权重压在一个 token 上)→ 梯度消失/异常 → loss spike。
方法
做法
谁在用
QK-Norm
对每个头的 q、k 先做 RMSNorm(第 14 课)再点积(在 RoPE 之前)
Qwen3、Gemma 3、OLMo 2、MiniMax-M2
Logit soft-capping
logits = cap · tanh(logits / cap),平滑限幅
Gemma 2:attention cap 50,最终输出 cap 30
QK-Clip (MuonClip = Muon 优化器 + QK-Clip)
监控每个头的最大 logit,超过阈值 τ 就按比例缩小该头的 W_q、W_k
Kimi K2
QK-Norm 最常用:把 q、k 的尺度锁住,点积就不会无限长大。Qwen3 技术报告写明「去掉 Qwen2 的 QKV-bias,引入 QK-Norm 以保证训练稳定」;Gemma 3 用 QK-Norm 替换 了 Gemma 2 的 soft-capping。
Soft-capping 的缺点:tanh 要插在 QKᵀ 和 softmax 之间,早期的 FlashAttention kernel 不支持,推理框架要专门适配。
QK-Clip 的背景很有意思:Kimi K2 用 MLA,而 K2 论文指出 QK-Norm 不适用于 MLA ,因为推理时做 weight absorption,Key 从未被完整地算出来(第 20 课),没地方插 norm。于是改为直接在权重上「剪」。K2 用 MuonClip 在 15.5T tokens 上训练,报告称全程没有一次 loss spike。
3. Tied Embedding:输入和输出共用一张表
输入 embedding 是 vocab × hidden 的查表矩阵;最后的 LM head 是 hidden × vocab 的投影。两者形状互为转置,可以共享同一份权重 (weight tying)。
账本(Qwen3,vocab 151,936):
Qwen3-0.6B: hidden 1024 → embedding 151936×1024 ≈ 156M,占总参约 1/4 → tie_word_embeddings: true
Qwen3-4B: hidden 2560 → ≈ 389M → true
Qwen3-8B: hidden 4096 → ≈ 622M,占比已不到 8% → false
小模型里 embedding 占比很大,共享能省下可观参数;大模型里占比小,分开学更灵活,所以大模型普遍不共享。GPT-2(第 16 课)就是 tied 的。
4. 其他常见「小改动」速查
去掉 bias :Llama 系、Qwen3 的线性层基本都无 bias(attention_bias: false);gpt-oss 则保留了 attention bias(attention_bias: true)。
z-loss (PaLM):给 softmax 的 log-partition(分母 Σe^z 取 log)加一个小惩罚,防止输出 logits 漂移。
Gated Attention :attention 输出乘一个 sigmoid gate(Qwen3-Next),缓解 attention sink 与数值问题。
SwiGLU clamp :gpt-oss config 里的 swiglu_limit: 7.0,给激活值限幅。
代码片段
先看本课主角 MTP(DeepSeek-V3 风格,只多预测 1 个 token):
class MTPModule(nn.Module):
def __init__(self, d, embed, lm_head):
self.norm_h, self.norm_e = RMSNorm(d), RMSNorm(d)
self.proj = nn.Linear(2 * d, d, bias=False) # 拼接后的 2d 压回 d
self.block = TransformerBlock(d) # 1 个完整的 block(自带 causal attention)
self.embed, self.lm_head = embed, lm_head # 与主模型共享,不额外占参数
def forward(self, h, next_ids): # h: [B,T,d] 主模型 hidden;next_ids: [B,T]
x = torch.cat([self.norm_h(h), self.norm_e(self.embed(next_ids))], dim=-1)
return self.lm_head(self.block(self.proj(x))) # [B,T,V]:位置 t 预测 t+2
# 训练:主 loss + λ·MTP loss
h = main_model.hidden(ids) # ids: [B, T+2]
loss_main = F.cross_entropy(main_model.lm_head(h[:, :-2]).flatten(0, 1),
ids[:, 1:-1].flatten()) # 位置 t → 预测 t+1(普通 LM)
logits_mtp = mtp(h[:, :-2], next_ids=ids[:, 1:-1]) # 位置 t 的 hidden + 真实的 t+1 token
loss_mtp = F.cross_entropy(logits_mtp.flatten(0, 1), ids[:, 2:].flatten()) # → 预测 t+2
loss = loss_main + 0.3 * loss_mtp # λ:V3 前 10T token 为 0.3,之后 0.1
三组切片是关键:h[:, :-2] 是位置 t,ids[:, 1:-1] 是 t+1(主模型的答案,也是喂给 MTP 的「提示」),ids[:, 2:] 是 t+2(MTP 的答案)。
推理时如果不做投机解码,整个 MTPModule 直接丢掉;做投机解码时,next_ids 换成主模型刚生成的 token,它的输出就是 draft(第 42 课)。
再看本课的几个「小改动」在代码里的位置:
class Attention(nn.Module):
def forward(self, x, pos):
q, k, v = self.q_proj(x), self.k_proj(x), self.v_proj(x)
q = q.view(B, T, n_head, hd); k = k.view(B, T, n_kv, hd)
q, k = self.q_norm(q), self.k_norm(k) # QK-Norm:每个 head 维度上的 RMSNorm
q, k = apply_rope(q, pos), apply_rope(k, pos) # 注意顺序:先 norm 再 RoPE
att = q @ k.transpose(-2, -1) / hd ** 0.5
if self.softcap: # Gemma 2 风格 soft-capping
att = self.softcap * torch.tanh(att / self.softcap)
...
class LM(nn.Module):
def __init__(self, vocab, d, tie=True):
self.embed = nn.Embedding(vocab, d)
self.lm_head = nn.Linear(d, vocab, bias=False)
if tie:
self.lm_head.weight = self.embed.weight # Tied embedding:同一个 Parameter 对象
q_norm/k_norm 的参数只有 head_dim 大小(如 128),参数量可以忽略,但加载权重时缺了它就错。
tanh(x/cap)*cap:x 小时近似恒等,x 大时平滑饱和在 ±cap。
Tied 时 checkpoint 里可能只存一份权重,推理框架加载要根据 tie_word_embeddings 做映射。
工程视角 / 真实世界
MTP 在推理引擎里 :SGLang、vLLM 都支持用 DeepSeek-V3 自带的 MTP 层做投机解码(类似 EAGLE 的接入方式),第 42 课详解。注意 MTP 层权重在 checkpoint 里是额外的一层(V3 的 HF 权重里是第 61 层),不开投机解码时加载会跳过它。
稳定性是训练团队的核心 KPI :Llama 3 论文、OLMo 2 报告都花了大量篇幅讨论 loss spike 和训练中断;这些架构上的小改动往往比调学习率更治本。
读 config 的习惯 :看到 qk_norm、attn_logit_softcapping、final_logit_softcapping、tie_word_embeddings、num_nextn_predict_layers,要知道各自改了 forward 的哪一行。
💬 能聊的要点
「DeepSeek-V3 的 MTP 是串行模块:用主模型 hidden state 加上真实下一个 token 的 embedding 再预测下下个 token;训练时增加监督密度,推理时可当 draft 做投机解码,接受率 85%–90%,约 1.8 倍 TPS。」
「训练稳定性的核心问题是 attention logits 爆炸:主流解法是 QK-Norm(Qwen3、Gemma 3),Gemma 2 用 tanh soft-capping,Kimi K2 因为 MLA 没法做 QK-Norm,改用 QK-Clip 直接缩放权重。」
「Tied embedding 在小模型上很值:Qwen3-0.6B 的 embedding 约占总参 1/4,所以 0.6B/4B 共享、8B 以上不共享。」
常见误区
❌ MTP 模型推理时必须用多 token 输出。✅ MTP 模块可以直接丢弃,主模型就是标准的 next-token 模型;是否用于投机解码是部署选择。
❌ QK-Norm 和 Pre-Norm 是一回事。✅ Pre-Norm 归一化的是进入 attention 的整条 hidden state;QK-Norm 是在投影之后、对每个头的 q 和 k 单独归一化。
❌ Soft-capping 就是 clamp。✅ 它用 tanh 平滑饱和,梯度处处存在;硬 clamp 在截断区梯度为 0。
自测(心算/口答即可)
为什么 DeepSeek-V3 的 MTP 模块要输入「真实的下一个 token 的 embedding」,而不是只用 h_t?答案 保持因果链:预测 t+2 时条件包含 t+1 的真实 token,和推理时逐 token 生成的条件一致,比并行头只靠 h_t 猜更准。
Gemma 2 的 final logit cap 是 30,某个 logit 原值是 3 和 300,soft-cap 后大约是多少?答案 30·tanh(0.1) ≈ 2.99;30·tanh(10) ≈ 30。小值几乎不变,大值被压到上限附近。
vocab 128K、hidden 4096,tie 能省多少参数?答案 128,256 × 4096 ≈ 525M 参数(BF16 约 1GB)。
延伸
🎬 Karpathy:Let's reproduce GPT-2 (124M) 中讲 weight sharing(wte 与 lm_head 共享)的部分
🎬 李沐:(无直接对应内容)
📄 Gloeckle et al. 2024, Better & Faster Large Language Models via Multi-token Prediction (arXiv:2404.19737)
📄 DeepSeek-V3 Technical Report §2.2 (arXiv:2412.19437);Kimi K2 §2.1 MuonClip (arXiv:2507.20534)
📄 Gemma 2 (arXiv:2408.00118);Gemma 3 Technical Report (arXiv:2503.19786)
下一课 → 26 · 解剖现代模型:Llama 3 / Qwen3 / DeepSeek-V3 / gpt-oss 配置表对照
26 · 解剖现代模型:Llama 3 / Qwen3 / DeepSeek-V3 / gpt-oss 配置表对照
⏱ 10 分钟 | 前置:25 · MTP 与其他训练技巧 | 难度:★★☆
一句话
2024–2025 年的主流开源 LLM 骨架几乎一样——Pre-Norm RMSNorm + RoPE + SwiGLU + GQA/MLA 的 decoder-only Transformer ;真正的差异集中在四个旋钮上:attention 怎么省 KV(GQA / MLA / SWA)、FFN 是否 MoE 以及多细、上下文怎么扩、稳定性小技巧 。学会读 config.json,就能在 1 分钟内说出一个新模型「长什么样、要多少显存」。
为什么要关心
新模型每月都在发布,技术报告动辄几十页;config.json 只有几十行,却包含部署需要的全部结构信息。
在 AI Infra 岗位上,拿到一个新模型的第一件事通常就是:算权重显存、算每 token KV Cache、判断需要哪些 kernel/并行方式——全都从 config 出发。
核心直觉
1. 大对照表(数据来自各官方 config.json 与技术报告)
怎么读这两张表 :每一列 是一个模型,每一行 是一个结构参数(括号里是 config.json 的字段名)。
层数 (num_hidden_layers):Transformer block 叠多少层。KV Cache、权重都按层线性增长。
hidden (hidden_size):每个 token 在层与层之间传递的向量长度,也是 embedding 维度。
Q heads / KV heads (num_attention_heads / num_key_value_heads):如 32 / 8 表示 32 个 Query 头、8 个 KV 头,即 GQA 组大小 4(第 19 课)。MoE 表的 Attention 行把它和 head_dim 写在一起:「GQA 64/4,hd 128」= 64 个 Q 头、4 个 KV 头、每头 128 维。
head_dim :每个头的向量维度,和 KV 头数一起决定 KV Cache。
FFN 中间维 (intermediate_size):SwiGLU 里升维后的宽度(第 15 课),dense 模型通常约 3–5 × hidden。MoE 表的 Expert 中间维 (moe_intermediate_size)是每个 Expert 的宽度,所以小得多。
上下文 :官方支持的最大长度;括号里写的是靠 YaRN 等扩展到的长度(第 23 课),「4K×40」= 原生训练长度 4K、factor 40。
每 token KV(BF16) :由本课代码里的公式算出,2 × 层数 × KV heads × head_dim × 2 字节,单位 KiB(1024 进制)。MLA 模型改用 层数 × 576 × 2 字节;gpt-oss 一半层是窗口 128 的 SWA,表里的数是按全部层都存满算的上限,长序列时实际约为它的一半。
总参 / 激活 :总参决定显存,激活参数决定每 token 计算量(第 21 课)。「前 3 层 dense」= 前 3 层用普通 FFN、之后才是 MoE(first_k_dense_replace)。
Routed / 激活 / Shared :路由 Expert 总数 / 每 token 选几个(top-k)/ 永远激活的共享 Expert 数(第 22 课)。
空白格表示没有值得一提的特殊设计;「同左」表示与左边一列相同。
Dense 模型
Llama 3.1 8B
Llama 3.1 70B
Llama 3.1 405B
Qwen3-8B
Qwen3-32B
层数
32
80
126
36
64
hidden
4096
8192
16384
4096
5120
Q heads / KV heads
32 / 8
64 / 8
128 / 8
32 / 8
64 / 8
head_dim
128
128
128
128
128
FFN 中间维
14336
28672
53248
12288
25600
vocab
128,256
128,256
128,256
151,936
151,936
上下文
128K
128K
128K
32K(YaRN 可到约 128K)
同左
每 token KV(BF16)
128 KiB
320 KiB
504 KiB
144 KiB
256 KiB
特色
rope_theta 5e5,llama3 rope scaling
QK-Norm,无 QKV bias
MoE 模型
Qwen3-235B-A22B
DeepSeek-V3 / R1
Kimi K2
gpt-oss-120b
gpt-oss-20b
总参 / 激活
235B / 22B
671B / 37B
约 1T / 32B
116.8B / 5.1B
20.9B / 3.6B
层数
94
61(前 3 层 dense)
61(前 1 层 dense)
36
24
hidden
4096
7168
7168
2880
2880
Attention
GQA 64/4,hd 128
MLA 128 头
MLA 64 头
GQA 64/8,hd 64
GQA 64/8,hd 64
局部 attention
无
无
无
SWA 128 与 full 交替 + sink
同左
Routed / 激活 / Shared
128 / 8 / 0
256 / 8 / 1
384 / 8 / 1
128 / 4 / 0
32 / 4 / 0
Expert 中间维
1536
2048
2048
2880
2880
vocab
151,936
129,280
163,840
201,088
201,088
上下文
32K(YaRN 约 128K)
128K(YaRN 4K×40 = 160K 位置,官方标称 128K)
128K
128K(YaRN 4K×32)
同左
每 token KV(BF16)
188 KiB
68.6 KiB
68.6 KiB
72 KiB(其中一半层只存 128 token)
48 KiB(同左)
预训练 tokens
36T
14.8T
15.5T
未公开
未公开
其他
QK-Norm,global-batch 均衡
MTP×1,aux-loss-free,FP8 训练
MuonClip / QK-Clip
MXFP4 权重,attention bias
16GB 显存可跑
2. 从表里能读出的趋势
骨架趋同 :全部是 decoder-only、Pre-Norm RMSNorm、RoPE、SwiGLU(gpt-oss 的 SwiGLU 带 clamp),基本都不共享 embedding(大模型)。
KV head 收敛到 8 或更少 :Llama 全系 8;Qwen3 MoE 只有 4;DeepSeek/Kimi 用 MLA 走到 576 维/层。
MoE 越来越细 :Expert 中间维 1536–2880,远小于 dense 的 hidden×3;激活 4–8 个。Kimi K2 的思路是「更多 Expert(384)、更少 attention 头(64)」——头数减半降低长上下文推理成本。
激活比例越来越低 :Qwen3 约 9%,V3 约 5.5%,K2 约 3%,gpt-oss-120b 约 4%。
hidden 不一定等于 heads × head_dim :Qwen3-235B 是 64×128 = 8192 ≠ 4096;gpt-oss 是 64×64 = 4096 ≠ 2880。o_proj 负责投影回 hidden。
3. 如何读 config.json(1 分钟流程)
① 骨架 architectures / model_type → 用哪套 modeling 代码(LlamaForCausalLM、DeepseekV3…)
② 尺寸 num_hidden_layers, hidden_size, intermediate_size, vocab_size
③ Attention num_attention_heads, num_key_value_heads, head_dim
有 kv_lora_rank / qk_rope_head_dim → MLA,KV 按 (kv_lora_rank + qk_rope_head_dim) 算
有 sliding_window / layer_types → 局部 attention,KV 分层计算
有 linear_* / full_attention_interval → hybrid(第 24 课)
④ FFN/MoE num_experts / n_routed_experts / num_local_experts, num_experts_per_tok,
moe_intermediate_size, n_shared_experts, first_k_dense_replace
⑤ 位置 rope_theta, rope_scaling(type, factor, original_max_position_embeddings), max_position_embeddings
⑥ 精度 torch_dtype, quantization_config(fp8 / mxfp4 / awq…) → 权重显存按这个算
⑦ 小技巧 tie_word_embeddings, *_softcapping, qk_norm, num_nextn_predict_layers, attention_bias
代码片段
# 从 config 粗算 dense GQA 模型的参数量与 KV Cache(忽略 norm 等小项)
def dense_stats(c, bytes_per=2):
h, L, V = c["hidden_size"], c["num_hidden_layers"], c["vocab_size"]
hd = c.get("head_dim") or h // c["num_attention_heads"]
nq, nkv, ff = c["num_attention_heads"], c["num_key_value_heads"], c["intermediate_size"]
attn = h * nq * hd * 2 + h * nkv * hd * 2 # q_proj + o_proj + k_proj + v_proj
mlp = 3 * h * ff # SwiGLU: gate / up / down
emb = V * h * (1 if c.get("tie_word_embeddings") else 2)
params = L * (attn + mlp) + emb
kv_per_token = 2 * L * nkv * hd * bytes_per # K 和 V
return params / 1e9, kv_per_token / 1024
llama3_8b = dict(hidden_size=4096, num_hidden_layers=32, vocab_size=128256,
num_attention_heads=32, num_key_value_heads=8, head_dim=128,
intermediate_size=14336, tie_word_embeddings=False)
print(dense_stats(llama3_8b)) # ≈ (8.03 B, 128.0 KiB)
每层 attention ≈ 41.9M、MLP ≈ 176M:FFN 占每层参数的 80% 以上 ,这就是 MoE 只改 FFN 的原因。
Embedding + LM head ≈ 1.05B,占 8B 模型的 13%;换成 Qwen3-0.6B 这种小模型,占比更高(第 25 课 tied embedding)。
MoE 模型要把 mlp 换成 n_experts × 3 × h × moe_ff(总参)与 k × …(激活)两套算法;MLA 模型的 KV 公式换成 L × (kv_lora_rank + qk_rope_head_dim) × bytes。
工程视角 / 真实世界
显存第一眼估算 :权重 = 总参 × 每参数字节。DeepSeek-V3 FP8 ≈ 671GB(单台 8×H200 141GB 可放下);gpt-oss-120b 的 MoE 权重用 MXFP4(约 4.25 bit),checkpoint 只有 60.8 GiB,官方称可放进单张 80GB GPU;Qwen3-235B BF16 约 470GB。
并行方式第一眼判断 :dense ≤ 70B → 单机 TP;MoE 大模型 → EP(+ DP-Attention,若是 MLA);KV head 数 < TP 度时 KV 会被复制(第 19 课)。
kernel 需求第一眼判断 :MLA → FlashMLA / FlashInfer MLA;attention sink + SWA → 需要支持 sink 的 attention kernel(gpt-oss 发布时各框架都专门做了适配);MXFP4 → 需要对应的 MoE GEMM kernel;hybrid → 需要 linear attention kernel 与 state 管理。
「同一骨架」的红利 :Kimi K2 的 architectures 直接写的是 DeepseekV3ForCausalLM,主流推理框架对 V3 的优化几乎可以原样复用——架构趋同让 infra 能跟上模型迭代。
💬 能聊的要点
「现代开源 LLM 的骨架基本统一为 Pre-Norm RMSNorm + RoPE + SwiGLU 的 decoder-only;差异主要在 KV 压缩方式(GQA、MLA、SWA)、MoE 粒度、上下文扩展和稳定性技巧。」
「拿到一个新模型我会先看 config:层数、hidden、Q/KV 头数和 head_dim 算 KV Cache;Expert 数、top-k、moe_intermediate_size 算总参和激活参;quantization_config 和 torch_dtype 算权重显存;rope_scaling 判断真实上下文。」
「趋势是 MoE 越来越稀疏:V3 激活约 5.5%,K2 约 3%,gpt-oss-120b 约 4%;这让训练更便宜,但把压力转移到显存容量和 all-to-all 通信上,是 infra 最需要关注的变化。」
常见误区
❌ 名字里的数字就是参数量。✅ gpt-oss-120b 实际 116.8B;Mixtral 8x7B 是 47B;Qwen3-235B-A22B 的「A22B」才是激活参数。
❌ num_key_value_heads 总能用来算 KV Cache。✅ MLA 模型(DeepSeek-V3、Kimi K2)要用 kv_lora_rank + qk_rope_head_dim;SWA 模型要区分 local/global 层。
❌ hidden_size = num_heads × head_dim 是恒等式。✅ 很多新模型不满足,一定读显式的 head_dim 字段。
自测(心算/口答即可)
Llama 3.1 405B 每 token KV Cache 多大?128K 上下文一条请求呢?答案 2 × 126 × 8 × 128 × 2 B = 516,096 B ≈ 504 KiB;× 131,072 ≈ 63 GiB。
Qwen3-235B-A22B 的 Q 头数 × head_dim 是多少?和 hidden 一样吗?答案 64 × 128 = 8192,而 hidden 是 4096,不相等;q_proj 是 4096→8192,o_proj 是 8192→4096。
看到 config 里有 kv_lora_rank: 512 和 n_routed_experts: 384,你能推断出什么?答案 这是 MLA + 细粒度 MoE 的 DeepSeek-V3 系架构(例如 Kimi K2);KV Cache 按 576 维/层算,部署需要 MLA kernel 和 EP。
延伸
🎬 Karpathy:Let's reproduce GPT-2 (124M) (从 HF 权重对照 GPT-2 结构,读 config 的起点)
🎬 李沐:「跟李沐学AI」论文精读 Llama 3.1
📄 The Llama 3 Herd of Models (arXiv:2407.21783);Qwen3 Technical Report (arXiv:2505.09388)
📄 DeepSeek-V3 Technical Report (arXiv:2412.19437);gpt-oss-120b & gpt-oss-20b Model Card (arXiv:2508.10925);Kimi K2 (arXiv:2507.20534)
🔗 直接看 config:huggingface.co///blob/main/config.json
下一课 → 27 · Pretraining Data:爬、洗、去重、配比
27 · Pretraining Data:爬、洗、去重、配比
⏱ 10 分钟 | 前置:26 · 解剖现代模型 | 难度:★☆☆
一句话
Pretraining 数据 = 以 Common Crawl 为主的互联网文本,经过「抽正文 → 语言识别 → 规则过滤 → 去重 → 模型打分过滤 → 按领域配比」这条 ETL 流水线,最后得到十几万亿(10T+)个 token;数据质量对模型的影响往往不亚于架构。
为什么要关心
架构这几年趋同(上一课的配置表大同小异),真正拉开差距的是数据 :同样 1.5B 参数、同样 token 数,换一个更好的过滤策略,benchmark 能差出一截。对后端/数据工程师来说好消息是:这一环本质是超大规模 ETL (Spark/Ray 集群、MinHash、分类器推理),是转型 AI 最顺手的切入点之一。
核心直觉
1. 规模:从 300B 到 36T
模型
Pretraining tokens
GPT-3(2020)
约 300B
Chinchilla(2022)
1.4T
Llama 2(2023)
2T
Llama 3(2024)
15T+(405B 约 15.6T)
DeepSeek-V3(2024)
14.8T
Qwen3(2025)
约 36T
怎么感受这个量级:1 个 token(模型读写的最小文本单位,第 6 课)在英文里约 0.75 个单词、约 4 字节文本。15T token ≈ 60 TB 纯文本——比一个中型数据仓库的一张大表还大,但比原始爬虫数据(PB 级 HTML)小两个数量级,中间的差距全被下面的流水线过滤掉了。
注意 Llama 3 8B 训练了 15T token,远超 Chinchilla(DeepMind 2022 年的 scaling law 论文,第 18 课)给出的「训练最省算力的点:每参数约 20 token」。原因:
Chinchilla 只优化训练 算力;
但模型训完要部署 ,推理成本按参数量算,要跑几亿次;
所以多花训练算力把小模型「喂饱」,换来一个更强、推理更便宜的小模型,总账更划算。
2. 流水线:以 FineWeb 为例
先认识原料:Common Crawl (CC)是一个非营利组织,每 1–2 个月爬一遍互联网并公开发布,每次发布叫一个 snapshot (快照)。格式有两种:WARC (原始 HTTP 响应,含完整 HTML)和 WET (CC 自己抽好的纯文本,质量一般)。
HuggingFace 的 FineWeb(2024)是公开得最透彻的一份:96 个 Common Crawl snapshot → 15T token。每一步都可以对应成你熟悉的 ETL 算子:
Common Crawl WARC(原始 HTML)
│ trafilatura 抽正文(比直接用 CC 自带的 WET 纯文本效果好)
▼
URL blocklist(成人站点)
│
fastText 语言识别(English score ≥ 0.65)
│ (fastText:Meta 开源的轻量文本分类器,CPU 上每秒处理海量文档)
│
质量规则:Gopher / C4 规则 + 自定义规则
│ (Gopher、C4 是 DeepMind / Google 早年数据集论文里公开的启发式规则集)
│ 例:以标点结尾的行占比过低、重复行过多、短行过多 → 丢
▼
MinHash 近似去重(每个 snapshot 内部去重)
│
PII 脱敏(邮箱、IP;PII = 个人身份信息)
▼
FineWeb 15T tokens ──► 教育质量分类器 ──► FineWeb-Edu 1.3T tokens
3. Dedup(去重):为什么、怎么做
网页重复极多(转载、模板页、镜像站)。重复数据会让模型背诵 、浪费算力、还会让评测集泄漏更严重。
Exact dedup :对文档/行做 hash,完全相同的去掉。便宜但抓不到「只改了几个字」的副本。
Fuzzy dedup(MinHash + LSH) :把文档切成 n-gram(连续 n 个词组成的片段)集合,用 MinHash 近似 Jaccard 相似度(两个集合「交集大小 / 并集大小」,1 = 完全相同),LSH(Locality-Sensitive Hashing,让相似的东西大概率落进同一个桶的 hash)分桶只比较可能相似的对。FineWeb 用 5-gram、112 个 hash 分 14 个 bucket × 8,大约 75% 相似即判重。
一个反直觉发现:FineWeb 做全局 跨 snapshot 去重后,老 snapshot 被删掉近 90%,而留下的数据反而质量更差 (被删的「重复」内容往往是优质、被广泛转载的)。最终选择每个 snapshot 内部去重。
4. Quality classifier:用模型筛数据
规则只能去掉明显的垃圾,「是否有信息量」需要模型判断。常见做法:
用强 LLM 给少量样本打分(FineWeb-Edu:Llama-3-70B-Instruct 给约 46 万网页打 0–5 分「教育价值」)
用这些标注训一个便宜的小分类器(embedding 模型 + 线性头,或 fastText)
对全量数据推理、按阈值过滤(FineWeb-Edu 取 ≥3,15T → 1.3T)
类比:这就像风控里「人工审几十万单 → 训一个轻量模型 → 线上全量打分」——贵的判断只做一次,便宜的模型做规模化。
Llama 3 也是这套路:用 Llama 2 标注数据,训 fastText 和 RoBERTa 类分类器筛网页。本质是用上一代模型给下一代模型筛数据 。
5. Data mixture(配比)
网页只是一部分,还要混入代码、数学、论文、书、多语言。配比决定模型「偏科」方向。Llama 3 报告的最终 mix 约为:50% 通用知识、25% 数学与推理、17% 代码、8% 多语言 。
配比怎么定?
小模型上做消融实验(ablation:每次只改一个变量看效果),再用 scaling law 外推大模型表现;
训练末期的 annealing(退火) :learning rate(每步参数更新的步长,第 2 课)线性降到 0 的最后阶段,上采样(提高采样比例)高质量数据(数学、代码、精选文本)。此时模型「学得最细」,喂好数据对 benchmark 提升明显。现代模型通常分阶段:大量通用数据 → 高质量数据 → 长上下文扩展。
代码片段
MinHash 去重的核心思想(伪代码,真实实现见 datatrove):
def shingles(text, n=5):
words = text.lower().split()
return {" ".join(words[i:i+n]) for i in range(len(words) - n + 1)}
def minhash_signature(text, hash_fns): # hash_fns: 112 个不同的 hash
s = shingles(text)
return [min(h(g) for g in s) for h in hash_fns] # 每个 hash 取最小值
# 两篇文档 signature 相同位置相等的概率 = Jaccard 相似度
def lsh_buckets(sig, n_buckets=14, per_bucket=8):
for b in range(n_buckets): # 切成 14 段,每段 8 个值
yield b, hash(tuple(sig[b*per_bucket:(b+1)*per_bucket]))
# 任一 bucket 的 key 相同 → 候选重复对 → 连通分量里只留一篇
为什么 min(h(g)) 能估 Jaccard:对随机 hash,两个集合的最小元素相同的概率正好等于 |A∩B| / |A∪B|。小例子:A、B 各 4 个 shingle、共享 3 个,并集 5 个,Jaccard = 3/5 = 0.6。一个随机 hash 把这 5 个元素随机排序,最小者落在「共享的 3 个」里的概率是 3/5——这时 A、B 的 min 相同。112 个 hash 里大约 67 个位置相等,67/112 ≈ 0.6。
LSH 分桶把 O(N²) 两两比较变成「按 key 做 group by」——在 Spark/Ray 上就是一次 shuffle。
分桶参数决定召回曲线:bucket 越多越容易判重,每桶越长越严格。代入数字:相似度 s 的两篇文档,某一桶 8 个值全相等的概率是 s^8,14 个桶至少一个撞上的概率是 1-(1-s^8)^14。s=0.75 时约 0.77,s=0.9 时约 0.9996,s=0.5 时仅约 0.05——形成一条以 0.75 附近为拐点的 S 曲线。
工程视角 / 真实世界
工具 :HuggingFace datatrove(FineWeb 的处理库)、NVIDIA NeMo Curator(GPU 加速去重/分类)、Spark/Ray 上的自研流水线。
Tokenize 后落盘 :训练时不会读原始文本,而是预先 tokenize 成二进制 shard(如 nanoGPT/llm.c 的 .bin 文件,uint16/uint32 数组),训练时 mmap 读取、按配比从多个数据源采样。
Decontamination :从训练集中去掉和评测集(MMLU、GSM8K 等)n-gram 重叠的文档,否则分数虚高。
合成数据 越来越重要:用强模型改写网页、生成教材式文本和数学题。Phi 系列是典型代表;各家的中后期训练都大量用到。
版权与合规 :robots.txt、opt-out、PII 过滤都是真实的工程约束。
💬 能聊的要点
「预训练数据本质是一条超大规模 ETL:抽正文、语言识别、启发式过滤、MinHash 去重、模型打分过滤、按领域配比;FineWeb 从 96 个 CC snapshot 得到 15T token,再用分类器筛出 1.3T 的 FineWeb-Edu。」
「去重不是越狠越好——FineWeb 发现全局去重反而留下更差的数据,最后改为 snapshot 内去重;被广泛转载的内容往往是好内容。」
「Llama 3 8B 训了 15T token,是 Chinchilla 最优的几十倍,因为推理成本跟参数量走,过度训练小模型在部署上更划算;配比和训练末期 annealing 上采样高质量数据对 benchmark 影响很大。」
常见误区
❌ 数据越多越好,全部 Common Crawl 直接训 ✅ 原始 CC 大部分是模板、广告、重复页;过滤掉大部分后训练效果更好,质量 > 数量。
❌ 去重只需要精确 hash ✅ 大量副本只差几个字符(时间戳、导航栏),必须用 MinHash/LSH 这类 fuzzy dedup。
❌ 配比是训练前一次定死的 ✅ 现代模型分阶段调整配比,训练末期 annealing 专门上采样高质量数据,长上下文阶段再换一批长文档。
自测(心算/口答即可)
8B 模型按 Chinchilla「20 token/参数」应训多少 token?Llama 3 8B 实际训了约多少倍?答案 约 160B;实际 15T,约 90 多倍。
MinHash signature 为什么能在不两两比较全文的情况下估计相似度?答案 对随机 hash 函数,两个集合 min-hash 相等的概率等于它们的 Jaccard 相似度;多个 hash 取平均即可估计,而 LSH 分桶让只有桶 key 相同的文档才进入比较。
用 LLM 直接给 15T token 的网页打分可行吗?业界怎么做?答案 太贵。做法是用大模型只标注几十万样本,训练一个小分类器(embedding+线性头/fastText),再对全量数据推理。
延伸
🎬 Karpathy:Deep Dive into LLMs like ChatGPT 的「pretraining data (internet)」一节(00:01:00 起,以 FineWeb 为例);Let's reproduce GPT-2 (124M) 的「datasets used in GPT-2, GPT-3, FineWeb (EDU)」一节(03:10:21 起)
🎬 李沐:「Llama 3.1论文精读 · 2. 预训练数据」
📄 Penedo et al., The FineWeb Datasets: Decanting the Web for the Finest Text Data at Scale (2024, arXiv:2406.17557);Meta, The Llama 3 Herd of Models §3.1 (2024);huggingface/datatrove
下一课 → 28 · Mixed Precision:FP32 / BF16 / FP8 与数值稳定性
28 · Mixed Precision:FP32 / BF16 / FP8 与数值稳定性
⏱ 10 分钟 | 前置:27 · Pretraining Data | 难度:★★☆
一句话
Mixed precision(混合精度)= 矩阵乘用低精度(BF16/FP8)跑得快、省显存,而 master weights(权重的高精度「正本」)和 optimizer state(优化器为每个参数额外记的状态,如 Adam 的 m、v)留在 FP32 保证更新不丢精度;于是 Adam 训练每个参数大约要 16 bytes ,这是后面所有分布式训练显存计算的起点。
为什么要关心
Tensor Core(GPU 里专门做小块矩阵乘的硬件单元,第 35 课)的吞吐和数据类型强相关:H100 上 FP8 的峰值算力是 BF16 的 2 倍,BF16 又远高于 FP32。精度选错,要么训练慢一倍,要么 loss 变 NaN。更重要的是,「7B 模型全参训练要多少显存」这种问题,答案就藏在各个 tensor 用什么精度里。
核心直觉
1. 浮点数 = 符号 + 指数(范围)+ 尾数(精度)
格式
sign / exponent / mantissa
最大值
说明
FP32
1 / 8 / 23
~3.4e38
传统默认
TF32
1 / 8 / 10
~3.4e38
只是 Tensor Core 内部计算格式,存储仍是 32 bit
FP16
1 / 5 / 10
65504
精度尚可,范围很窄
BF16
1 / 8 / 7
~3.4e38
范围与 FP32 相同,精度只有约 2–3 位十进制
FP8 E4M3
1 / 4 / 3
448
精度稍好,一般用于前向
FP8 E5M2
1 / 5 / 2
57344
范围稍大,有时用于梯度
记住一句话:exponent 位决定范围,mantissa 位决定精度 。BF16 的设计就是「把 FP32 的尾数砍掉 16 位」,范围不变,所以训练时基本不会溢出——这是它取代 FP16 成为 LLM 训练默认格式的原因。
🧩 难点拆解:exponent / mantissa,以及 BF16 vs FP16
先直觉 :浮点数就是二进制的科学计数法 值 = ±1.xxx × 2^e。
exponent(指数)= e,决定「小数点能挪多远」→ 能表示多大、多小的数(范围 )
mantissa(尾数)= 1.xxx 里的 xxx,决定「有效数字有几位」→ 相邻两个数隔多近(精度 )
FP16 和 BF16 都是 16 bit、都有 1 位符号,剩下 15 位怎么分就是全部差别:FP16 给指数 5 位、尾数 10 位;BF16 给指数 8 位、尾数 7 位。
再代数字 :
要表示的数
FP16(5 位指数,e ∈ −14..15)
BF16(8 位指数,e ∈ −126..127)
100000 ≈ 1.53 × 2^16
e=16 超上限 → inf
正常,≈ 99840
梯度 1e-8 ≈ 1.34 × 2^-27
比最小可表示数(约 6e-8)还小 → 0
正常
1/3
0.33325(误差 0.02%)
0.33398(误差 0.2%)
1.0 的下一个数
1 + 2^-10 ≈ 1.00098
1 + 2^-7 ≈ 1.0078
一句话总结 :FP16 用范围换精度,BF16 用精度换范围;训练中梯度/激活横跨很多个数量级,「不溢出、不归零」比「多几位有效数字」重要,所以 BF16 胜出。
2. FP16 的问题与 loss scaling
FP16 最小正规数约 6e-5(再往下还有精度更差的 subnormal,最低到约 6e-8),很多梯度比这还小,会 underflow(下溢)成 0 ——这一步的梯度就等于没算。解决办法是 loss scaling:
loss × S(如 2^16)→ backward → 梯度都放大 S 倍,躲开 underflow
→ optimizer step 前 grad ÷ S 还原
→ 如果出现 inf/NaN:跳过这步,S 减半;连续若干步正常:S 加倍
这就是 PyTorch GradScaler 做的事。BF16 范围和 FP32 一样,通常不需要 loss scaling 。
🧩 难点拆解:loss scaling 为什么有效
先直觉 :像测量细菌时把单位从「米」换成「微米」——数值本身变大了,就不会被「米」的最小刻度抹成 0;最后换算回来即可。
再代数字 (S = 2^16 = 65536):
某个真实梯度是 1e-8,FP16 里直接是 0。
把 loss 乘 S。求导是线性的(d(S·L)/dw = S · dL/dw),所以每个 梯度都自动放大 S 倍:1e-8 × 65536 ≈ 6.6e-4,FP16 能表示了。
反向结束后先把梯度转成 FP32,再除以 S:6.6e-4 / 65536 = 1e-8,原值找回。
代价:大梯度也被放大了。若某个梯度本来是 2,乘 65536 = 131072 > 65504 → inf。这时这一步作废、S 减半重试;连续 N 步(PyTorch 默认 2000)都没溢出就把 S 翻倍——动态地把整批梯度「挪进」FP16 的可表示窗口。
一句话总结 :loss scaling 不改变数学结果,只是把梯度整体平移到 FP16 装得下的数值区间,算完再挪回来。
3. Master weights:为什么权重要留一份 FP32
BF16 只有 7 位尾数,1.0 附近相邻两个可表示数相差 2^-7 ≈ 0.0078。一次更新 w -= lr * g 可能只有 1e-5 量级:
bf16: 1.0 + 0.00001 = 1.0 # 更新被舍入吞掉了!
fp32: 1.0 + 0.00001 = 1.00001
所以优化器在 FP32 的 master copy 上累加更新,前向/反向时再 cast 成 BF16 用。Adam 的 m(一阶矩:梯度的滑动平均,类似「动量」)、v(二阶矩:梯度平方的滑动平均,用来给每个参数自适应地缩放步长)同理保留 FP32——它们也是一点点累加出来的,同样怕被舍入吞掉。
4. 「16 bytes per param」账本
标准 BF16 混合精度 + Adam,每个参数:
BF16 weights (前向/反向用) 2 B
BF16 gradients 2 B
FP32 master weights 4 B
FP32 Adam m 4 B
FP32 Adam v 4 B
-------------------------------
合计 16 B (不含 activations)
7B 模型:7e9 × 16 B ≈ 112 GB → 单张 80GB H100 放不下,哪怕 batch=1
70B 模型:≈ 1.12 TB → 至少要 14 张 80GB 卡才放得下这些 state,还没算 activation
ZeRO 论文把后面 12 字节(master + m + v)称为 optimizer states,它们占了大头——这正是下一课 ZeRO 要切分的东西。(有的实现直接用 FP32 存梯度或省掉 BF16 权重副本,数字会在 12–18 B 之间浮动,但 16 B 是最常用的估算口径。)
Activations(前向时每层的中间结果,反向求梯度要用,所以得先存着)则和 batch size × seq len × 层数成正比,通常用 activation checkpointing(重计算) 换显存:前向只存少量「检查点」,反向时重算其余部分。因为反向约是前向的 2 倍计算,一次训练步 ≈ 3 份前向,多重算一次前向 ≈ 多花 1/3。
5. FP8 训练
FP8 只有 2–3 位尾数、范围也窄,必须先乘一个 scale(缩放因子)把数值挪进可表示区间——和上面 loss scaling 是同一个思路。问题是:整个 tensor 共用一个 scale 时,只要里面有一个离群大值(outlier),scale 就得迁就它,其余小值全被压成 0。所以关键是细粒度 scaling (每一小块数据用自己的 scale):
DeepSeek-V3:activation 按 1×128 tile、weight 按 128×128 block 各自算 scale;GEMM(通用矩阵乘)累加定期提升到 FP32,避免 FP8 Tensor Core 累加精度不足;master weights、optimizer state 和部分敏感算子(embedding、attention、norm 等)仍保持高精度。
Blackwell 硬件原生支持 MX 格式(如 MXFP8:每 32 个元素共享一个 scale),把「block scaling」做进了硬件。
代码片段
PyTorch 里 BF16 混合精度训练(和 Karpathy 在 GPT-2 复现视频里的写法一致):
model = GPT(config).cuda() # 参数默认是 FP32(= master weights)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, fused=True)
torch.backends.cuda.matmul.allow_tf32 = True # FP32 matmul 走 TF32 Tensor Core
for x, y in loader:
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
logits, loss = model(x, y) # matmul 自动 cast 成 BF16
loss.backward() # 梯度落在 FP32 参数的 .grad 上
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step(); opt.zero_grad(set_to_none=True)
# 若用 FP16:需要 scaler = torch.amp.GradScaler("cuda")(旧写法 torch.cuda.amp.GradScaler 已弃用)
# scaler.scale(loss).backward(); scaler.step(opt); scaler.update()
autocast 只把「安全」的算子(matmul、conv)降到 BF16,softmax、layernorm、loss 等会保留 FP32。
这里参数本身就是 FP32,所以 autocast 天然就是「FP32 master + BF16 计算」;大框架(Megatron、FSDP mixed precision)则显式维护两份。注意这种写法梯度也是 FP32,账本变成 4+4+4+4 = 16 B,总数恰好一样,只是构成不同。
clip_grad_norm_(…, 1.0):如果所有梯度拼成的向量长度超过 1.0,就等比例缩小——防止一次异常大的梯度把权重「炸飞」。
FP16 必须配 GradScaler,BF16 不用——这就是差别的全部来源:指数位数。
工程视角 / 真实世界
BF16 是事实标准 :Llama 3、Qwen、Mistral 等主流开源模型均以 BF16 训练和发布权重。Llama 3 报告的 MFU(Model FLOPs Utilization,实际有效算力 ÷ 硬件峰值算力)是按 BF16 峰值算的 38–43%。
FP8 训练已规模化 :DeepSeek-V3(671B MoE,14.8T token)是第一批公开的超大规模 FP8 训练;NVIDIA Transformer Engine 提供 fp8_autocast,Megatron-LM 集成了它。
FP16 仍存在 :V100/T4 等老卡没有 BF16,只能 FP16 + loss scaling;部分推理场景也用 FP16。
数值问题排查 :loss spike、NaN 常见于 attention logits 过大、softmax 溢出,QK-Norm(第 25 课)和 z-loss(给 softmax 前的 logits 加一个惩罚项,不让它们无限变大)就是为此服务的。
💬 能聊的要点
「BF16 和 FP32 一样有 8 位指数,所以范围相同、不需要 loss scaling;代价是只有 7 位尾数,因此 master weights 和 Adam 状态必须留在 FP32,否则小的更新会被舍入吞掉。」
「混合精度 Adam 每参数约 16 字节:2 字节 BF16 权重、2 字节梯度、12 字节 FP32 master+m+v;7B 模型光这些就 112GB,这就是为什么全参训练 7B 就得上 ZeRO/FSDP。」
「FP8 训练的关键是细粒度 scaling——DeepSeek-V3 对 activation 按 1×128、weight 按 128×128 分块量化,并定期把累加提升到 FP32;Blackwell 的 MXFP8 把这种 block scaling 做进了硬件。」
常见误区
❌ 混合精度就是把模型全部转成 BF16 ✅ 只有计算和部分存储用低精度;master weights、optimizer state、reduction 类算子保持 FP32。
❌ BF16 比 FP16 更精确 ✅ 恰好相反,BF16 尾数更少、精度更低;它赢在范围 ,训练中溢出/下溢才是主要问题。
❌ 16 bytes/param 就是训练的全部显存 ✅ 还有 activations(随 batch×seq 增长,长序列时可能是大头)、临时 buffer 和通信 buffer。
自测(心算/口答即可)
13B 模型用 BF16 混合精度 Adam 全参训练,不算 activation,需要多少显存?答案 13e9 × 16 B ≈ 208 GB。
为什么 FP16 训练需要 loss scaling,BF16 不需要?答案 FP16 只有 5 位指数,小梯度会 underflow 成 0,需要先放大再还原;BF16 有 8 位指数,范围与 FP32 相同。
推理时只加载 BF16 权重,7B 模型要多少显存(不算 KV Cache)?和训练差几倍?答案 约 14 GB;训练的 112 GB 是它的 8 倍。
延伸
🎬 Karpathy:Let's reproduce GPT-2 (124M) 的「Tensor Cores, timing the code, TF32 precision」(01:28:14)和「float16, gradient scalers, bfloat16」(01:39:38)两节
🎬 李沐:「Zero 论文精读」里对混合精度 Adam 显存构成的讲解
📄 Micikevicius et al., Mixed Precision Training (2017);Micikevicius et al., FP8 Formats for Deep Learning (2022);DeepSeek-AI, DeepSeek-V3 Technical Report §3.3 FP8 Training (2024)
下一课 → 29 · 分布式训练(上):DP、ZeRO、FSDP
29 · 分布式训练(上):Data Parallel、ZeRO、FSDP
⏱ 10 分钟 | 前置:28 · Mixed Precision | 难度:★★☆
一句话
Data Parallel(数据并行)让每张卡跑不同的数据、用 all-reduce 同步梯度;ZeRO/FSDP 在此基础上把 optimizer states、gradients、parameters 依次切片 分到各卡,用「用时再 all-gather」换掉冗余副本,让每卡显存从 16Ψ 降到约 16Ψ/N(Ψ = 参数量,N = 卡数)。
为什么要关心
上一课算过:7B 模型全参训练光 state 就要 112GB,一张卡放不下。数据并行是最简单、最通用的扩展方式,也是所有「3D/4D 并行」的最外层;ZeRO/FSDP 则是今天几乎所有训练框架(DeepSpeed、PyTorch FSDP、Megatron distributed optimizer)的标配。面试里「ZeRO-1/2/3 分别切了什么、通信量变多少」是高频题。
核心直觉
先认识三个 collective(集合通信) 原语——它们来自 MPI,由 NVIDIA 的 NCCL 库在 GPU 上实现。设 4 张卡各有一个向量:
原语
做什么
结果
all-reduce
所有卡的向量逐元素求和(或平均),每卡都拿到完整结果
4 卡都有完整的和
reduce-scatter
求和,但结果切成 4 段,第 k 卡只拿第 k 段
每卡有 1/4 的和
all-gather
每卡贡献自己那 1 段,拼起来每卡都拿到完整向量
4 卡都有完整向量
可以看出 all-reduce = reduce-scatter + all-gather,这个等式是理解 ZeRO 的钥匙。
1. DDP:每人一份完整模型,梯度求平均
DDP(DistributedDataParallel)里每个进程管一张卡,进程编号叫 rank 。一次训练迭代的 global batch 被切成 N 份,每卡处理的那一份叫 micro-batch :
batch 切成 N 份
GPU0 GPU1 GPU2 GPU3
[model] [model] [model] [model] ← 完整副本
fwd/bwd fwd/bwd fwd/bwd fwd/bwd
g0 g1 g2 g3
└──────── all-reduce(mean) ────────┘ ← 每卡拿到相同的平均梯度
opt.step opt.step opt.step opt.step ← 各自更新,结果一致
All-reduce 通常用 ring 算法实现 = reduce-scatter + all-gather,每卡收发约 2 × 梯度大小,与卡数 N 基本无关——这是 DP 能扩到上千卡的原因。
PyTorch DDP 把梯度分成 bucket,反向传播还没算完就开始通信 (计算和通信 overlap)。
问题:每张卡都存完整的 16Ψ 字节(Ψ = 参数量),N 张卡就有 N 份一模一样的 optimizer state——纯粹的冗余。
🧩 难点拆解:ring all-reduce 为什么每卡只发 ~2 倍数据
先直觉 :最朴素的做法是找一个「中心节点」(参数服务器)收齐 N 份梯度再广播回去——中心节点要收 N×D 字节,卡越多越堵。Ring 的做法像「接力传水桶」:N 张卡连成环,每卡只和左右邻居说话,把数据切成 N 块,每一步每卡同时「发一块给右边、收一块自左边」,所有链路同时满载,没有热点。
再代数字 (N = 4,每卡梯度 D = 1 GB,切成 4 块,每块 250 MB):
Reduce-scatter 阶段 :3 步(N−1 步)。每步每卡发 250 MB,收到的块加到自己对应块上。3 步后,每卡恰好有一块 是 4 张卡之和。每卡发了 3 × 250 = 750 MB。
All-gather 阶段 :再 3 步,把各自那块「完整的和」沿环传一圈,每卡又发 750 MB。
每卡总计发送 2 × (N−1)/N × D = 1.5 GB。换成 N = 64:2 × 63/64 × 1 GB ≈ 1.97 GB。卡数涨 16 倍,每卡流量几乎不变 ,上限就是 2D。
按节点间每卡 400 Gb/s ≈ 50 GB/s 算,同步 1 GB 梯度约 1.5–2 GB / 50 GB/s ≈ 30–40 ms。
类比在哪里不成立:步数是 2(N−1),每步有固定的启动延迟(latency),卡数上千时延迟项不可忽略,所以 NCCL 大规模下也会用 tree 等算法。
一句话总结 :ring all-reduce 把「中心节点收 N 份」变成「每卡发 2 份」,带宽成本与卡数无关,这是 DP 能横向扩展的根本。
2. ZeRO:把冗余的东西切片
ZeRO(Zero Redundancy Optimizer,DeepSpeed,2019)的思路:每卡只「拥有」1/N 的状态,需要时再通信拿。
Stage
切什么
每卡显存(混合精度 Adam)
通信量(相对 DDP)
DDP
不切
2Ψ + 2Ψ + 12Ψ = 16Ψ
1×
ZeRO-1
optimizer states
4Ψ + 12Ψ/N
1×
ZeRO-2
+ gradients
2Ψ + 14Ψ/N
1×
ZeRO-3
+ parameters
16Ψ/N
约 1.5×
ZeRO 论文的例子:7.5B 模型、64 卡,每卡 120GB → 31.4GB → 16.6GB → 1.9GB 。
类比:DDP 像每台机器都存一整份全量表;ZeRO 像把表按主键分区 ,每台只存自己那 1/N 的分区,需要别人的行时再临时拉过来。不成立之处:数据库查询通常只碰少量分区,而训练每一步都要用到全部参数 ,所以「临时拉取」是每步必做、必须和计算重叠的全量操作。
🧩 难点拆解:ZeRO-1/2/3 到底切了什么(4 卡走一遍)
先直觉 :16Ψ 字节可以拆成三类——BF16 参数(2Ψ)、BF16 梯度(2Ψ)、optimizer states(FP32 master + m + v,12Ψ)。ZeRO 的三个 stage 就是依次把这三类从「每卡一份完整」改成「每卡 1/N」。优化器更新是逐元素 的(第 i 个参数的更新只需要第 i 个梯度、第 i 个 m/v),所以天然可以按下标切给不同的卡。
再代数字 (Ψ = 1B 参数,N = 4 卡,每卡负责 1/4 = 250M 个参数的「更新权」):
BF16 参数
BF16 梯度
FP32 master+m+v
每卡合计
DDP
2 GB(全)
2 GB(全)
12 GB(全)
16 GB
ZeRO-1
2 GB(全)
2 GB(全)
3 GB(1/4)
7 GB
ZeRO-2
2 GB(全)
0.5 GB(1/4)
3 GB(1/4)
5.5 GB
ZeRO-3
0.5 GB(1/4)
0.5 GB(1/4)
3 GB(1/4)
4 GB(+ 临时 gather 的一两层)
以 ZeRO-2 的一步为例,看 GPU1(负责参数下标 250M–500M):
前向/反向:用完整 BF16 参数算自己 micro-batch 的梯度。
Reduce-scatter 梯度:GPU1 只收到下标 250M–500M 这一段的「4 卡梯度之和」,其余段的梯度算完就可以扔。
用这段梯度 + 自己的 FP32 master/m/v 更新这 250M 个参数。
All-gather 新参数:4 张卡各贡献一段,拼回完整 BF16 参数,进入下一步。
ZeRO-3 再往前一步:连第 1 步的完整参数都不常驻,每层用之前临时 all-gather。
一句话总结 :ZeRO 把「每卡一份完整状态」改成「每卡只负责 1/N 下标的更新」,reduce-scatter 把梯度送到负责人,all-gather 把新参数发回给大家。
为什么 ZeRO-1/2 通信量不变?DDP 的 all-reduce 本来就是 reduce-scatter + all-gather 两步。ZeRO-2 只是把顺序改成:梯度 reduce-scatter (每卡只拿到自己负责那 1/N 的梯度和)→ 更新自己那 1/N 参数 → all-gather 参数。总字节数一样,只是不再冗余存储。
ZeRO-3 连参数也切:每层前向前 all-gather 该层参数,用完立刻丢掉;反向时再 all-gather 一次,再 reduce-scatter 梯度。多出来一次 all-gather,所以约 1.5 倍通信。
3. FSDP:PyTorch 原生的 ZeRO-3
FSDP(Fully Sharded Data Parallel)本质就是 ZeRO-3,以「一层或一组层」为单位(FSDP unit)做 all-gather / 释放。prefetch(预取)= 算第 i 层时就提前发起第 i+1 层的 all-gather,让通信藏在计算后面:
forward layer i: all-gather(params_i) → compute → free(params_i)
同时 prefetch all-gather(params_{i+1}) ← overlap
backward layer i: all-gather(params_i) → compute grads → reduce-scatter(grads_i) → free
🧩 难点拆解:FSDP 的 gather/free 时间线
先直觉 :把完整模型想成一本放不进书包的厚书。FSDP 的做法是每人只带几页(shard),读到第 i 章时找同事把第 i 章的其余页借来(all-gather),读完立刻还掉(free),同时已经在借第 i+1 章(prefetch)。书包里任何时刻只有「自己的几页 + 一两章完整内容」。
再代数字 (70B 模型、80 层、64 卡;每层约 0.86B 参数,BF16 完整一层约 1.7 GB):
时间 → t0 t1 t2 t3
通信流: gather L0 gather L1 gather L2 gather L3 ...
计算流: compute L0 compute L1 compute L2 ...
显存中完整层: L0 L0,L1 L1,L2 L2,L3 ← 最多约 2 层 ≈ 3.4 GB
常驻 shard: 16 × 70e9 / 64 ≈ 17.5 GB(不随时间变)
反向时顺序反过来(L79 → L0),每层多一步:算完梯度立刻 reduce-scatter ,本卡只留自己那 1/64 的梯度,完整梯度随即释放。
于是每卡峰值 ≈ 17.5 GB 常驻 + 约 3.4 GB 临时完整层 + activations,而不是 1.12 TB。
一句话总结 :FSDP 用「按层借、用完还、提前借下一层」把完整模型的显存占用摊成了时间上的流水,峰值只多出一两层。
常用策略:FULL_SHARD(≈ZeRO-3)、SHARD_GRAD_OP(≈ZeRO-2)、HYBRID_SHARD(节点内切片、节点间复制——all-gather 走 NVLink,跨节点只做梯度 all-reduce)。
FSDP2(fully_shard)改用 DTensor(PyTorch 的「分布式张量」,记录每个 tensor 在哪些卡上怎么切)按参数切片,比 FSDP1 的 flat-parameter 更容易与 TP 等组合。
Llama 3 报告里的 DP 维度就是 FSDP。
4. DP 的极限
Activations 不会被 ZeRO 切 :它们和本卡的 micro-batch 相关,仍需 activation checkpointing。
Global batch size 会膨胀 :N 卡 × micro-batch,batch 太大会伤收敛(每步看的样本越多,一个 epoch 里更新的步数越少,超过某个临界值后多加的样本几乎不带来额外收益)。
单层太大 :ZeRO-3 每次仍要在一张卡上 all-gather 出完整的一层;层的计算本身也无法被切开。这就需要下一课的 Tensor/Pipeline Parallel。
ZeRO-Offload / ZeRO-Infinity :把 optimizer state 或参数放到 CPU 内存甚至 NVMe,用 PCIe 带宽换显存,适合卡少的微调场景。
代码片段
DDP 与 FSDP2 在 PyTorch 里的用法(torchrun --nproc_per_node=8 train.py 启动):
import torch, torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.distributed.fsdp import fully_shard # FSDP2, PyTorch ≥ 2.6
dist.init_process_group("nccl") # 每个进程 = 一张 GPU
rank = dist.get_rank(); torch.cuda.set_device(rank % 8)
# --- 方案 A:DDP,每卡完整副本 ---
model = DDP(GPT(cfg).cuda())
# --- 方案 B:FSDP2,按 block 切片(≈ ZeRO-3)---
model = GPT(cfg).cuda()
for block in model.blocks:
fully_shard(block) # 每个 Transformer block 是一个通信单元
fully_shard(model) # 剩余参数(embedding 等)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4)
sampler = torch.utils.data.DistributedSampler(ds) # 每个 rank 读不同的数据
loader = torch.utils.data.DataLoader(ds, batch_size=B, sampler=sampler) # B = micro-batch
for x, y in loader:
loss = model(x, y); loss.backward() # DDP: bucket all-reduce; FSDP: reduce-scatter
opt.step(); opt.zero_grad() # FSDP: 每卡只更新自己的 1/N
训练循环几乎不变,差别全在模型包装:DP 是对训练代码侵入性最小的并行方式。
fully_shard(block) 的粒度决定了 all-gather 的峰值显存和 overlap 效果:太粗则峰值高,太细则通信次数多。
DistributedSampler 保证各 rank 看不同数据;Karpathy 的 GPT-2 复现里是手写 data loader 按 rank 切分。
工程视角 / 真实世界
DeepSpeed :zero_optimization.stage: 1/2/3 一个配置项切换;ZeRO++ 进一步做量化通信和节点内二级切片。
Megatron-LM :--use-distributed-optimizer 就是 ZeRO-1,和 TP/PP 组合使用;DeepSeek-V3 的 DP 维度也是 ZeRO-1。
通信库 :NCCL 负责 all-reduce / reduce-scatter / all-gather,节点内走 NVLink(H100 约 900GB/s,双向合计),节点间走 InfiniBand/RoCE(每卡常见 400Gb/s)。带宽差一个数量级,所以「节点内切、节点间复制」的 HSDP 很常见。
Gradient accumulation :显存不够放大 micro-batch 时,累积多个 micro-batch 的梯度再同步一次,DDP 里用 no_sync() 跳过中间步骤的 all-reduce。
💬 能聊的要点
「ZeRO-1/2/3 依次切 optimizer states、gradients、parameters;1 和 2 的通信量和 DDP 一样(all-reduce 本来就是 reduce-scatter + all-gather),ZeRO-3 多一次参数 all-gather,大约 1.5 倍。」
「FSDP 就是 PyTorch 原生的 ZeRO-3:按层 all-gather 参数、算完即丢、prefetch 下一层做 overlap;HYBRID_SHARD 在节点内切、节点间复制,用来适配 NVLink 和 IB 的带宽差。」
「ZeRO 解决的是冗余状态,不解决 activation 和单层计算过大的问题;所以大模型训练里 DP/FSDP 通常是最外层,内层还要叠 TP/PP。」
常见误区
❌ ZeRO-3 把模型「切开」了,和 Tensor Parallel 一样 ✅ ZeRO-3 只是存储 切片,计算前会 all-gather 回完整层,每卡仍做完整的层计算;TP 是真正把矩阵乘切开计算。
❌ 卡越多 all-reduce 越慢 ✅ Ring all-reduce 每卡通信量约 2×数据量,与 N 基本无关;延迟项随 N 增长,但大消息下带宽项主导。
❌ ZeRO 能省掉 activation 显存 ✅ ZeRO 只切模型状态;activation 靠 checkpointing、SP/CP 解决。
自测(心算/口答即可)
70B 模型、64 卡 ZeRO-3,每卡模型状态约多少?ZeRO-1 呢?答案 ZeRO-3:16×70e9/64 ≈ 17.5 GB。ZeRO-1:4×70 = 280 GB + 12×70/64 ≈ 13 GB,约 293 GB,放不下。
ZeRO-2 中梯度同步用的是什么集合通信?为什么不用 all-reduce?答案 Reduce-scatter:每卡只负责更新 1/N 参数,只需要那 1/N 的梯度之和,不必每卡都拿到全量梯度。
为什么 DDP 可以在反向还没结束时就开始通信?答案 反向从最后一层往前算,后面层的梯度先就绪;DDP 按 bucket 分组,一个 bucket 的梯度齐了就发起 all-reduce,与前面层的计算 overlap。
延伸
🎬 Karpathy:Let's reproduce GPT-2 (124M) 的「gradient accumulation」(02:34:09)与「distributed data parallel (DDP)」(02:46:52)两节
🎬 李沐:「Zero 论文精读」;《动手学深度学习》第 12 章「计算性能」中 12.5 多 GPU 训练、12.7 参数服务器
📄 Rajbhandari et al., ZeRO: Memory Optimizations Toward Training Trillion Parameter Models (2019);Zhao et al., PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel (2023);PyTorch 文档 torch.distributed.fsdp.fully_shard
下一课 → 30 · 分布式训练(下):TP、PP、EP、CP
30 · 分布式训练(下):Tensor / Pipeline / Expert / Context Parallel
⏱ 10 分钟 | 前置:29 · 分布式训练(上) | 难度:★★★
一句话
当一层放不下、一条序列太长、专家太多时,就要把模型本身 切开:TP(Tensor Parallel)切矩阵、PP(Pipeline Parallel)切层、EP(Expert Parallel)切专家、CP(Context Parallel)切序列;它们的通信模式和带宽需求各不相同,所以实际训练按「通信越重越往里放」的原则拼成 4D/5D 并行。
为什么要关心
DP/FSDP 只能切存储,不能切计算。训练 405B dense 或 671B MoE 时,必须组合多种并行,而选错组合 MFU 可能直接腰斩。这一课的词汇(column/row parallel、bubble、1F1B、all-to-all、ring attention)在推理侧(第 44 课的 TP/EP 推理)会原样再出现一次。
核心直觉
1. Tensor Parallel(TP):切矩阵,Megatron 式
一个 MLP(即 Transformer 里的 FFN,第 15 课):Y = GeLU(X·A)·B。X 是输入 [tokens, d],A 是 [d, 4d] 的升维矩阵,B 是 [4d, d] 的降维矩阵,GeLU 是逐元素的非线性激活函数。Megatron-LM(2019)的切法:
A 按列切(column parallel) B 按行切(row parallel)
X ──┬──► X·A₁ → GeLU → ·B₁ ──► Y₁ (partial) ─┐
└──► X·A₂ → GeLU → ·B₂ ──► Y₂ (partial) ─┴─ all-reduce(sum) → Y
GPU0 / GPU1 各持有一半权重
第一个矩阵按列 切:每卡得到输出的一部分列,GeLU 是逐元素的,可以直接在本地做,不用通信 。
第二个矩阵按行 切:每卡得到完整形状但只是部分和,一次 all-reduce 求和。
Attention 同理:按 head 切 Q/K/V(column),输出投影按行切(row)。
结果:每个 Transformer layer 前向 2 次 all-reduce、反向 2 次。通信频繁且在关键路径上 ,所以 TP 基本只在节点内(NVLink)用,TP ≤ 8 是常态。
🧩 难点拆解:为什么「先列切、再行切」只要一次 all-reduce
先直觉 :矩阵乘 X·A 有两种切法——
按列 切 A:每卡算出输出的一部分列 ,结果是「完整的、但只有一部分」→ 可以直接接逐元素运算。
按行 切 A(输入 X 也要按列切):每卡算出的是完整形状的部分和 ,必须先加起来才是真值。
GeLU 是非线性的:GeLU(a+b) ≠ GeLU(a)+GeLU(b),所以 GeLU 之前必须拿到真值。第一刀用列切,恰好让每卡拿到「真值的一部分列」,GeLU 本地做;GeLU 的输出正好是按列切开的,又正好是第二个矩阵行切所需要的输入。两刀咬合,中间零通信,只在最后求一次和。
再代数字 (2 卡,1 个 token,d=2,4d 用 4 列;为了心算用 ReLU 代替 GeLU):
X = [1, 2],A = [[1,0,1,-1],[0,1,1,1]] → 完整的 H = X·A = [1, 2, 3, 1]
列切:GPU0 拿 A 的第 1–2 列 → 算出 [1, 2];GPU1 拿第 3–4 列 → 算出 [3, 1]。ReLU 本地做,数值不变。
B 的 4 行是 [1,0]、[0,1]、[1,1]、[2,0],行切:GPU0 拿前 2 行,GPU1 拿后 2 行。
GPU0:1·[1,0] + 2·[0,1] = [1, 2](部分和)
GPU1:3·[1,1] + 1·[2,0] = [5, 3](部分和)
all-reduce:[1,2] + [5,3] = [6, 5],与不切时的 H·B 完全相同。
反例:如果第一刀用行切,GPU0/GPU1 拿到的是 H 的部分和,ReLU 前就得 all-reduce 一次,后面还得再一次——2 次。
一句话总结 :列切的输出「完整但分段」,正好喂给行切;非线性夹在中间也不用通信,一个 MLP 只需结尾一次 all-reduce。
Sequence Parallel(Megatron-SP) :TP 区域之外的 LayerNorm/Dropout 本来每卡重复计算、重复存 activation;SP 把它们按序列维切开,把 all-reduce 拆成 reduce-scatter + all-gather,通信量不变、activation 显存再降。
2. Pipeline Parallel(PP):切层,像流水线
把 L 层分成 p 个 stage(例如 80 层、p=4 → 每 stage 20 层),每个 stage 一组 GPU,stage 之间只传 activation(点对点 send/recv,通信量小,能跨节点)。
问题是 bubble(气泡) :流水线填满和排空时有卡在空等。把 batch 切成 m 个 micro-batch(下图 F = 前向、B = 反向,数字是 micro-batch 编号;GPipe 是 Google 2019 年的流水线方案,1F1B = one-forward-one-backward 调度):
GPipe(先全部 F,再全部 B) p=4, m=4(示意)
S0: F1 F2 F3 F4 . . . . . . B4 B3 B2 B1
S1: . F1 F2 F3 F4 . . . . B4 B3 B2 B1 .
... ↑ 空白处都是 bubble
1F1B(稳定后一前一后交替)
S0: F1 F2 F3 F4 B1 F5 B2 F6 B3 ...
🧩 难点拆解:bubble 占比怎么算出来的
先直觉 :就是 CPU 指令流水线 / 工厂装配线。p 个工位串成一条线,第一个零件要走 p 格才到最后一个工位——最后一个工位在开头要空等 p−1 格;收尾时第一个工位也要空等 p−1 格。
再代数字 (把每个 micro-batch 在一个 stage 上的「前向+反向」记为 1 格时间):
p = 4,m = 4:总时长 = m + p − 1 = 7 格,每个 stage 真正干活 4 格,空等 3 格 → bubble = 3/7 ≈ 43%。
p = 4,m = 32:总时长 35 格,空等仍是 3 格 → 3/35 ≈ 8.6%。
通式:bubble = (p−1) / (m+p−1)。空等的格数只和 p 有关,m 越大越能「摊薄」它。
为什么不把 m 调到无穷大?global batch 是固定的(受收敛约束),m 越大每个 micro-batch 越小,GPU 矩阵乘吃不饱;GPipe 调度下 activation 还要存 m 份。
一句话总结 :bubble 是流水线「灌满 + 排空」各 p−1 格的固定成本,只能靠更多 micro-batch 摊薄或靠 interleaved/DualPipe 调度压缩。
bubble 占比约 (p-1)/(m+p-1):micro-batch 越多,bubble 越小。
1F1B 的 bubble 和 GPipe 一样,但每个 stage 同时在飞的 micro-batch 不超过 p 个,activation 显存大幅下降 。
Interleaved 1F1B :每卡负责多个不连续的小 stage(virtual stage),bubble 再缩小约 v 倍,代价是更多点对点通信。
DeepSeek-V3 的 DualPipe :双向流水线 + 把前向/反向的计算与通信块交错重叠,进一步压 bubble、掩盖 EP 通信。
3. Expert Parallel(EP):切专家
回顾第 21 课:MoE(Mixture of Experts)层有很多个 FFN「专家」,每个 token 由 router(一个小线性层)打分、只选分数最高的 top-k 个专家计算,再按 gate 权重(router 给的归一化分数)加权。EP 就是把专家分布在不同 GPU 上。每个 token 经 router 选 top-k 专家后:
dispatch: all-to-all 把 token 发到持有目标专家的 GPU
compute: 各 GPU 跑自己的专家
combine: all-to-all 把结果发回 token 原来的 GPU,按 gate 加权求和
每个 MoE 层前向 2 次 all-to-all,数据量与 token 数 × hidden × top-k 成正比。All-to-all 跨节点时很吃 IB 带宽,这是 MoE 训练和推理的核心瓶颈,也是负载均衡(第 22 课)重要的原因:一个专家过热,整个 all-to-all 等最慢的那张卡。
🧩 难点拆解:EP 的 all-to-all
先直觉 :就是 MapReduce 的 shuffle 。每张卡上的 token 是 mapper 的输出,key = 它选中的专家 id;dispatch 把每条记录发到「持有该专家」的 reducer;专家算完,combine 再 shuffle 一次发回原卡。和 all-reduce 不同,all-to-all 是每对卡之间各发一份不同的数据 。
再代数字 (4 卡、8 个专家、每卡 2 个专家;每卡 1000 个 token,top-2;hidden = 7168,BF16 每个 token 向量约 14 KB):
每卡有 1000 × 2 = 2000 份「token → 专家」请求。
若路由均匀,每卡发给每张卡约 500 份,其中 1500 份要离开本卡 → 1500 × 14 KB ≈ 21 MB。
combine 再回传同样多。每层 2 次,DeepSeek-V3 有 58 个 MoE 层(前 3 层是 dense),一次前向就是上百次 shuffle。
如果某张卡的专家很「热」,收到 3200 份而不是 2000 份,它的计算时间多 60%,其余 3 张卡都要等它——这就是负载均衡直接变成吞吐的原因。
类比不成立之处:MapReduce shuffle 一个 job 做一次、可以落盘;这里每层都做、同步阻塞、全在显存里,必须和计算重叠才不拖垮 MFU。
一句话总结 :EP 的 all-to-all 是「按专家 id 做的 shuffle」,一去一回,最慢的那张卡决定整层速度。
4. Context Parallel(CP):切长序列
128K 序列的 activation 一张卡放不下。CP 沿序列维切分,每卡持有一段 token:
非 attention 部分(MLP、Norm)逐 token 独立,直接各算各的。
Attention 需要看全部 K/V:Ring Attention 把 K/V 块沿环传一圈,边传边算;Llama 3 用的是 all-gather K/V 的变体(GQA 下 K/V 较小);DeepSpeed-Ulysses 用 all-to-all 把「按序列切」转成「按 head 切」再算 attention。
Causal mask(因果掩码:每个 token 只能看它前面的 token)导致前面的 token 算得少,需要把序列按「头尾配对」切分做负载均衡。
🧩 难点拆解:Context Parallel 怎么切 attention
先直觉 :MLP 是「逐行处理」的 map,按 token 切天然可并行;attention 是「每个 query 要和所有 key 做 join」,切开后每卡的 Q 还得见到别人的 K/V。CP 的做法:Q 留在本地不动,只搬 K/V ;借助 online softmax(第 40 课:softmax 可以分块算、最后再合并),一块一块地把别人的 K/V 拿来算、累加结果即可。
再代数字 (128K token,CP = 4):
每卡持 32K token,这些 token 的 activation 只占 1/4 显存。
Ring 方式:4 步,每步用本地 32K 个 Q 对「当前手上那块」32K 个 K/V 算 attention,同时把这块 K/V 传给右邻居——通信被计算盖住。
为什么 Llama 3 敢直接 all-gather K/V:405B 用 GQA,每层每 token 的 K+V 只有 8 heads × 128 × 2 × 2 B = 4 KB,128K token 也只有 512 MB/层,gather 过来可接受。
负载均衡:causal 下第 i 块要看前 i 块,越靠后越贵。把序列切成 8 块 c0..c7,GPU0 拿 c0+c7、GPU1 拿 c1+c6、GPU2 拿 c2+c5、GPU3 拿 c3+c4,每卡工作量都是 1+8 = 2+7 = … = 9 个单位。
一句话总结 :CP 让每卡只存一段序列,Q 不动、K/V 轮转或 gather,用 online softmax 拼出完整 attention,再用头尾配对抹平 causal 的负载不均。
5. 怎么组合:通信越重越靠内
最内层 TP (每层多次 all-reduce) → 节点内 NVLink
CP (每层 attention 交换 K/V)
PP (只在 stage 边界点对点) → 可跨节点
最外层 DP/FSDP(每步一次梯度同步,可 overlap)
MoE 层另有 EP(all-to-all),常与 DP 维度复用
真实例子:
训练
GPU
并行配置
Llama 3 405B,8K 序列
16,384 H100
TP 8 × CP 1 × PP 16 × DP(FSDP) 128
Llama 3 405B,128K 序列
16,384 H100
TP 8 × CP 16 × PP 16 × DP 8
DeepSeek-V3(671B MoE)
2,048 H800
PP 16(DualPipe)× EP 64(跨 8 节点)× ZeRO-1 DP,不用 TP
DeepSeek-V3 报告的说法是:通过精细的显存优化(重计算 RMSNorm/MLA up-projection、EMA 放 CPU 等),训练时可以不用「代价高昂的 TP」(H800 的 NVLink 带宽也比 H100 低);它把精力花在 all-to-all kernel 和计算-通信 overlap 上,并限制每个 token 最多路由到 4 个节点,控制跨节点流量。
代码片段
Megatron 风格 TP MLP 的核心(伪代码,省略反向的自定义 autograd):
class ColumnParallelLinear(nn.Module): # W: [in, out/tp]
def forward(self, x): # x 在各 TP rank 上相同
return x @ self.weight # 输出: 本 rank 的 out/tp 列,无通信
class RowParallelLinear(nn.Module): # W: [in/tp, out]
def forward(self, x_shard): # 输入: 上一层切好的那部分列
y_partial = x_shard @ self.weight # 完整形状,但只是部分和
dist.all_reduce(y_partial, group=tp_group) # 唯一一次通信
return y_partial
class TPMLP(nn.Module):
def __init__(self, d, tp):
self.up = ColumnParallelLinear(d, 4 * d // tp)
self.down = RowParallelLinear(4 * d // tp, d)
def forward(self, x):
return self.down(F.gelu(self.up(x))) # GeLU 逐元素,本地做
列切 + 行切的配对,让中间激活无需通信 ,一个 MLP 只要一次 all-reduce。
反向时对称:ColumnParallel 的输入梯度需要一次 all-reduce(Megatron 论文里的 f/g 算子)。
SwiGLU(第 15 课)的 gate/up 两个矩阵都按列切即可,逻辑相同。
工程视角 / 真实世界
框架 :Megatron-LM / Megatron-Core 是 TP/PP/EP/CP 的事实标准;PyTorch 原生有 DTensor + torchtitan;DeepSpeed 提供 Ulysses 和 ZeRO。
Rank 排布 :Megatron 的 --tensor-model-parallel-size、--pipeline-model-parallel-size、--context-parallel-size、--expert-model-parallel-size,剩下的卡数就是 DP。Llama 3 按 [TP, CP, PP, DP] 的顺序分组,保证 TP 组落在同一节点内。
MFU :Llama 3 论文报告 405B 在不同规模的并行配置下(8K–16K GPU)BF16 MFU 约 38–43%;DeepSeek-V3 全部训练约 2.788M H800 GPU hours。
推理复用 :TP、EP 在推理侧同样存在,但推理没有反向和 optimizer,取舍不同(第 44 课)。
💬 能聊的要点
「Megatron TP 把第一个矩阵按列切、第二个按行切,中间的逐元素激活不用通信,每层前向只要 2 次 all-reduce;但它在关键路径上且很频繁,所以只放在 NVLink 域内,一般 TP≤8。」
「PP 的 bubble 约 (p-1)/(m+p-1),1F1B 不减少 bubble 但把在飞 micro-batch 限制在 p 以内,省 activation;interleaved 和 DualPipe 才进一步压 bubble。」
「组合原则是通信越密越往里:TP 在节点内,PP 可以跨节点,DP 最外层;MoE 的 EP 靠 all-to-all,DeepSeek-V3 干脆不用 TP,用 PP16 + EP64 + ZeRO-1,并限制 token 最多去 4 个节点。」
常见误区
❌ TP 和 ZeRO-3 都是「把参数切开」,效果一样 ✅ ZeRO-3 计算前把参数 gather 回来,计算不切;TP 每卡只算部分矩阵乘,activation 也被切开。
❌ 1F1B 比 GPipe bubble 小 ✅ 非 interleaved 的 1F1B bubble 与 GPipe 相同,它省的是 activation 显存。
❌ Sequence Parallel 和 Context Parallel 是一回事 ✅ Megatron-SP 只切 TP 区域外的 Norm/Dropout,依附于 TP;CP 切整条序列(含 attention),是独立的并行维度。
自测(心算/口答即可)
PP=16,每步 64 个 micro-batch,bubble 占比约多少?答案 15/(64+15) ≈ 19%。
为什么 TP 通常不跨节点,而 PP 可以?答案 TP 每层前向反向各有 2 次 all-reduce,数据量是整份 activation,且阻塞计算;PP 只在 stage 边界做点对点 send/recv,频率和数据量都小得多。
Llama 3 405B 在 16384 卡上从 8K 序列切到 128K 序列,为什么 DP 从 128 降到 8?答案 长序列 activation 放不下,需要 CP=16 切序列;总卡数不变,TP×PP 不变,只能从 DP 里挪出 16 倍:128/16=8。
延伸
🎬 Karpathy:TP/PP 没有专门视频;Let's reproduce GPT-2 (124M) 的 DDP 一节是理解 process group / rank 的好起点
🎬 李沐:「Megatron LM 论文精读」「GPipe 论文精读」;「Llama 3.1论文精读 · 4. 训练infra」
📄 Shoeybi et al., Megatron-LM (2019);Narayanan et al., Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM (2021,PTD-P 与 interleaved 1F1B);DeepSeek-AI, DeepSeek-V3 Technical Report §3.2 (2024)
下一课 → 31 · SFT 与 Chat Template
31 · SFT 与 Chat Template:base model 如何变成助手
⏱ 10 分钟 | 前置:30 · 分布式训练(下) | 难度:★☆☆
一句话
SFT(Supervised Fine-Tuning,监督微调)就是用「对话格式的文本」继续做 next-token prediction(给定前文预测下一个 token,第 5 课):chat template 用 special token(tokenizer 里保留的、有特殊含义的单个 token)把 system / user / assistant 轮次编码成一条 token 序列,loss 只算在 assistant 回复上,模型便学会了「扮演助手」。
为什么要关心
Pretraining 出来的 base model 只会「续写互联网」:你问它一个问题,它可能接着再生成三个类似的问题。SFT 是把它变成可用产品的第一步,而且训练代码与预训练几乎一样——变的是数据格式和 loss mask 。对 infra 来说,chat template 是训练和推理之间的「协议」:推理服务(vLLM/SGLang 的 OpenAI 兼容接口)拼 prompt 用的模板必须和训练时一致,否则效果莫名其妙变差。
核心直觉
1. Base model vs Instruct model
Prompt: "法国的首都是哪里?"
Base: "\n德国的首都是哪里?\n意大利的首都是哪里?..." ← 像在续写一张考卷
Instruct: "法国的首都是巴黎。" ← 像一个助手在回答
两者架构、参数量完全相同,区别只在于 SFT(和后续 RL)用什么数据继续训练过。Karpathy 的说法:你和 ChatGPT 对话时,得到的是对「OpenAI 雇佣的标注员会怎么回答」的统计模拟。
2. Chat template:把对话压扁成一条序列
模型只认一维 token 序列,所以要用 special token 标出角色和轮次边界。类比:这就像给 TCP 字节流设计一个应用层协议——没有「消息」的概念,就得用分隔符/帧头把「谁在说话、什么时候说完」编码进流里。Llama 3 的格式:
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
You are a helpful assistant.<|eot_id|><|start_header_id|>user<|end_header_id|>
法国的首都是哪里?<|eot_id|><|start_header_id|>assistant<|end_header_id|>
法国的首都是巴黎。<|eot_id|>
Qwen 系列用的 ChatML 风格:
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
法国的首都是哪里?<|im_end|>
<|im_start|>assistant
法国的首都是巴黎。<|im_end|>
要点:
<|eot_id|>、<|im_end|> 是单个 token ,不是字符串拼出来的;tokenizer 必须把它们识别为 special token,否则用户输入里写个 <|im_end|> 就能「注入」轮次。
推理时 prompt 以 assistant 头结尾(add_generation_prompt=True),模型接着生成回复,生成到 <|eot_id|> 就停——这就是模型「知道何时闭嘴」的原因 。
模板定义在 HuggingFace tokenizer_config.json 的 chat_template 字段(Jinja 模板,和 Flask/Ansible 用的是同一种模板语言),推理框架直接读它。
工具调用(tool calls)、推理模型的 <think>...</think> 同样是模板的一部分。
3. Loss masking:只在 assistant 回复上算 loss
tokens: [sys hdr][system text][user hdr][question][asst hdr][answer][eot]
labels: [ -100 ][ -100 ][ -100 ][ -100 ][ -100 ][answer][eot]
↑ 只有这里贡献 loss
为什么?我们要模型学会如何回答 ,而不是学会如何提问 或复述 system prompt。-100 是 PyTorch cross_entropy 的默认 ignore_index(label 等于它的位置直接跳过,不参与 loss 也不参与求平均)。注意 <|eot_id|> 要算 loss,否则模型学不会停止。
多轮对话里,一般每个 assistant 轮都算 loss(一次前向覆盖所有轮次)。
🧩 难点拆解:loss mask 和「错一位」
先直觉 :模型在每个位置 都会输出一个对「下一个 token」的预测(logits:词表上每个 token 的原始分数)。训练时我们给每个位置配一个「标准答案」label;mask 就是把不想考的位置的答案涂掉(设为 -100),这些位置的预测对错都不计分。
再代数字 (一条极简样本,7 个 token):
位置 t
输入 token
该位置要预测的 label(= 第 t+1 个 token)
算 loss?
0
<user hdr>
法国首都?
❌ -100
1
法国首都?
<eot>
❌ -100
2
<eot>
<asst hdr>
❌ -100
3
<asst hdr>
巴黎
✅
4
巴黎
。
✅
5
。
<eot>
✅(学会停)
6
<eot>
—(没有下一个)
丢弃
注意第 3 行:第一个被监督的预测发生在 assistant header 上 ——看到「轮到助手说话了」就要吐出回答的第一个字。这就是代码里 logits[:-1] 对 labels[1:] 错开一位的原因。
loss 只在 3 个 ✅ 位置上取平均。若不 mask,7 个位置里有一半是在学「预测用户会问什么」。
真实数据里 prompt 常常比回答长得多(长 system prompt、RAG 文档),不 mask 的话大部分梯度都花在了背 prompt 上。
一句话总结 :label 就是「右移一位的输入」,把非 assistant 部分的 label 改成 -100,模型就只为「回答 + 结束符」负责。
4. 数据:少而精
InstructGPT:SFT 阶段约 1.3 万条标注员写的 prompt-回复。
LIMA(2023):仅 1000 条精选样本就让 65B base 模型表现出不错的对话能力,提出「Superficial Alignment Hypothesis」:知识在 pretraining 学到,SFT 主要教格式和风格 。
现代做法:人工种子数据 + 大量合成数据 (强模型生成、rejection sampling 挑最好的回答——同一个 prompt 采样多条回答,用 reward model 或规则打分,只留最好的进训练集)。Llama 3 的 post-training 就是多轮「SFT → rejection sampling → DPO」迭代。
代码片段
用 HuggingFace tokenizer 构造一条 SFT 样本并做 loss mask(简化版,只处理最后一轮):
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")
msgs = [{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "法国的首都是哪里?"}]
answer = "法国的首都是巴黎。"
# prompt 部分:以 assistant header 结尾
prompt_ids = tok.apply_chat_template(msgs, add_generation_prompt=True,
return_dict=True)["input_ids"] # transformers v5 默认返回 dict
# 完整对话:包含 answer 和结尾的 <|eot_id|>
full_ids = tok.apply_chat_template(msgs + [{"role": "assistant", "content": answer}],
return_dict=True)["input_ids"]
input_ids = full_ids
labels = [-100] * len(prompt_ids) + full_ids[len(prompt_ids):] # 只监督回复
# 训练: loss = F.cross_entropy(logits[:-1], labels[1:], ignore_index=-100)
训练循环和预训练完全一样,唯一区别是 labels 里大量 -100。
真实框架(TRL 的 SFTTrainer、LLaMA-Factory 等)会处理多轮 mask,并做 packing :把多条短样本拼进一个长序列(避免 padding 浪费算力),用变长 attention(cu_seqlens:记录每条样本在拼接序列里的起止位置,如 [0, 300, 812, 2048])防止样本间互相 attend。
这个「先编 prompt、再编完整对话、按长度切」的写法依赖一个前提:prompt 的 token 恰好是完整对话 token 的前缀。有的模板(如会删除历史 <think> 内容的推理模型模板)不满足,真实框架会逐轮对齐。
注意模板细节(如 Llama 3.1 模板会自动插入日期等 system 信息),实际 token 以 tok.decode(full_ids) 打印为准。
工程视角 / 真实世界
推理侧的坑 :OpenAI 兼容接口 /v1/chat/completions 在服务端调用 apply_chat_template;如果模型仓库的模板有 bug 或被替换,输出会出现复读、不停止、角色错乱。vLLM 提供 --chat-template 参数覆盖。
Stop token :服务端要把 <|eot_id|> / <|im_end|> 配为 stop token;早期不少部署事故就是只配了 <|end_of_text|>,导致模型一直生成。
新增 special token :如果基座没有这些 token,SFT 时要扩展 vocab 并 resize embedding(输入端把 token id 映射成向量的表),新 embedding 需要训练;LoRA 微调时要记得让 embedding/lm_head(输出端把向量映射回词表分数的矩阵)可训练。
SFT 的规模 :比预训练小几个数量级(百万到十亿级 token),单机或几台机器、几小时到几天即可,常配合 LoRA(第 34 课)。
💬 能聊的要点
「SFT 在算法上就是继续做 next-token prediction,区别在于数据是 chat template 格式化的对话,且 loss 只算 assistant 部分(其余 label 设为 -100);模型学会在 <|eot_id|> 处停止,也是因为这个 token 被算进了 loss。」
「Chat template 是训练和推理之间的协议:special token 必须是单 token、推理时模板和 stop token 要与训练一致,很多线上『模型变笨/不停止』问题最后查下来都是模板不匹配。」
「LIMA 用 1000 条样本就调出不错的助手,说明知识主要来自预训练,SFT 更多是教格式和风格;现代 post-training 大量依赖合成数据和 rejection sampling。」
常见误区
❌ SFT 让模型学到新知识 ✅ 主要学格式、风格、遵循指令;强行用 SFT 灌新知识容易引发幻觉(模型学会「自信地编」)。
❌ Loss 应该对整条对话计算 ✅ 通常只对 assistant 回复(含结束 token)计算;对 prompt 算 loss 会让模型去学用户的说话方式。
❌ Chat template 只是字符串拼接,随便写 ✅ 每个模型家族的 special token 不同,用错模板等于分布外输入,效果显著下降。
自测(心算/口答即可)
一个模型生成回答后一直不停止,最可能的两个原因?答案 ① 推理服务没有把 <|eot_id|>/<|im_end|> 配成 stop token;② SFT 时结束 token 被 mask 掉没算 loss,模型没学会生成它(或模板不匹配)。
为什么 <|im_end|> 必须是一个独立的 special token,而不能让 BPE 把它切成若干普通 token?答案 一是生成时要能用单个 token id 判断停止;二是防注入——用户文本里的同名字符串会被切成普通 token,不会被当成真正的轮次边界。
base model 和 instruct model 的参数结构有区别吗?答案 没有,架构和参数量相同,只是权重经过了 SFT/RL 继续训练(可能额外训练了 special token 的 embedding)。
延伸
🎬 Karpathy:Deep Dive into LLMs like ChatGPT 的「pretraining to post-training」(00:59:23)与「post-training data (conversations)」(01:01:06)两节,专门讲对话如何被 tokenize 成序列
🎬 李沐:「InstructGPT」论文精读(SFT 数据的来源与规模);「Llama 3.1论文精读 · 5. 模型训练过程」(post-training 部分)
📄 Ouyang et al., Training language models to follow instructions with human feedback (InstructGPT, 2022);Zhou et al., LIMA: Less Is More for Alignment (2023);HuggingFace 文档「Chat Templates」
下一课 → 32 · RLHF、PPO 与 DPO
32 · RLHF、PPO 与 DPO:对齐的三种路线
⏱ 10 分钟 | 前置:31 · SFT 与 Chat Template | 难度:★★☆
一句话
RLHF 先用人类偏好对(A 比 B 好)训练一个 Reward Model,再用 PPO 让模型在「多拿 reward」和「别离原模型太远(KL)」之间优化,需要同时维护 4 个模型;DPO 证明这个目标可以改写成直接在偏好对上算的一个分类式 loss,只需 2 个模型、不用在线采样。
为什么要关心
SFT 只能模仿示范答案,但「写出完美答案」难,「判断两个答案哪个更好」容易——偏好数据便宜得多,也能表达「更有帮助、更安全、别啰嗦」这类难以示范的目标。InstructGPT 用这套方法让 1.3B 模型在人类评估中胜过 175B 的 GPT-3,ChatGPT 由此诞生。对 infra 来说,PPO 是第一个「训练循环里嵌着推理」的负载,是下一课 RL infra 的前身。
核心直觉
先对齐几个 RL(Reinforcement Learning,强化学习)词汇,映射到 LLM 上:
policy (策略 π)= 正在训练的语言模型本身:给定前文,输出下一个 token 的概率分布。π_θ 里的 θ 指模型参数。
action = 生成一个 token;一条完整回答 = 一个 episode (回合)。
reward = 回答结束后拿到的一个分数(标量)。
log-prob = 模型生成某段文本的概率取对数(各 token 的 log 概率相加),越接近 0 越「有把握」。
KL (KL divergence,衡量两个概率分布差多远的指标,相同为 0)。
1. 三步走:InstructGPT 流程
Step 1 SFT 人写示范答案 → 监督微调 (约 13k prompts)
Step 2 Reward Model 对同一 prompt 的多个回答排序 → 训练打分模型 (约 33k prompts)
Step 3 PPO 模型生成 → RM 打分 → 强化学习更新 (约 31k prompts)
2. Reward Model:把偏好变成一个标量
RM 通常用 SFT 模型初始化,把 LM head 换成输出一个标量的 head。训练数据是偏好对 (x, y_w, y_l)(w = chosen,l = rejected),用 Bradley-Terry 模型:
loss = -log σ(r(x, y_w) - r(x, y_l))
大白话:让 chosen 的分数比 rejected 高,差得越多 loss 越小。只关心相对 分数,绝对值无意义。
🧩 难点拆解:Bradley-Terry
先直觉 :就是国际象棋的 Elo 等级分 。每个选手有一个分数,两人对局时「A 赢 B 的概率」只取决于分差:P(A 赢) = σ(r_A − r_B),σ 是 sigmoid 函数(把任意实数压到 0–1,σ(0) = 0.5)。人类标注「A 比 B 好」就是一局对战结果;训练 RM 就是反推每个回答的「等级分」,让观测到的胜负最可能发生。
再代数字 :
RM 给 chosen 打 2.0、rejected 打 0.5 → 分差 1.5 → σ(1.5) ≈ 0.82 → loss = −log 0.82 ≈ 0.20(判对了,loss 小)。
反过来 chosen 0.5、rejected 2.0 → σ(−1.5) ≈ 0.18 → loss ≈ 1.70(判反了,loss 大,梯度会把两者拉开)。
两者都 +100(102.0 vs 100.5)→ 分差仍是 1.5,loss 仍是 0.20。所以 RM 分数的绝对值没有意义,只能比大小。
类比在哪里不成立:Elo 只给下过棋的选手打分;RM 要给从没见过的 新回答打分,靠的是泛化——一旦 policy 生成了 RM 没见过的「怪」回答,分数就可能离谱,这就是 reward hacking 的土壤。
一句话总结 :Bradley-Terry 把「A 比 B 好」的成对标注变成一个可学的打分函数,只学分差、不学绝对值。
3. PPO:4 个模型的舞蹈
优化目标:max E[r(x, y)] - β · KL(π_θ || π_ref) —— 多拿奖励,但别偏离 SFT 模型太远。
prompt x
│
┌────────▼────────┐
│ Actor π_θ(训练)│── 生成回答 y(= 推理!)
└────────┬────────┘
┌──────────────┼─────────────────┬──────────────────┐
▼ ▼ ▼ ▼
Reward Model Reference π_ref Critic V(训练) Actor 自己
(冻结) 打分 r (冻结) 算 KL 惩罚 估计每个 token 的 算新旧 log-prob
期望回报 → advantage → clipped 更新
模型
作用
是否训练
Actor(policy)
生成回答,被优化的对象
✅
Critic(value model)
估计 baseline,算 advantage(GAE)
✅
Reward Model
给完整回答打分
❌
Reference
SFT 模型副本,计算 KL
❌
Advantage :「这个 token 比预期好多少」,用 critic 估计的 value 作为 baseline 降低方差。
Clip :PPO 限制新旧策略概率比在 [1-ε, 1+ε] 内,防止一步更新太大。
KL 惩罚 :没有它,模型会找到 RM 的漏洞——reward hacking ,比如越写越长、堆砌讨好的词,RM 分数上升但人类觉得更差。
GAE (Generalized Advantage Estimation):用 critic 的逐 token value 估算每个 token advantage 的标准方法,细节可以先跳过。
🧩 难点拆解:PPO 的 4 个模型与 KL 惩罚
先直觉 :把 actor 想成一个在练写作文的学生——
Reward Model = 阅卷老师,只看整篇给一个总分;
Reference = 学生「训练前的自己」,用来提醒「别为了讨好老师把文风改得面目全非」;
Critic = 学生对自己的预期(「这种题我一般能拿几分」),拿实际得分和预期比,才知道这次是超常 还是失常 ;
Actor = 学生本人,唯一真正要交付的模型。
再代数字 :
KL 惩罚 :某位置 actor 选了「绝对」,它给这个 token 的概率 0.6,reference 只给 0.1 → 该 token 的 log-ratio = ln(0.6/0.1) ≈ 1.79。β = 0.05 时惩罚 ≈ 0.09。
如果 200 个 token 都这样偏离,累计惩罚 ≈ 18,而 RM 满分也就 2–3 分——偏得太远「不划算」,模型只会在真正能涨分的地方偏离。
最终用于训练的 reward = RM 分数 − β × Σ_token log(π_θ/π_ref)。
Advantage :critic 在某个 token 处预测「从这里往后能拿 1.2 分」,实际拿到 1.5 → advantage = +0.3 → 提高这个 token 的概率;实际只有 0.8 → −0.4 → 压低。没有 critic 的话,只能拿整篇总分去奖励每个 token,信号噪声很大。
Clip :ε 通常 0.2,新旧策略对同一 token 的概率比最多按 1.2 或 0.8 计入目标,一步走不远。
一句话总结 :RM 给方向、reference + KL 拴住别跑偏、critic 把整篇的分数分摊成逐 token 的「超出预期多少」,actor 据此小步更新。
代价:4 个同量级大模型(70B 级别就是 4×70B)同时在显存里,还要在训练循环里做自回归生成,工程复杂度和资源开销都很高,且超参敏感。
4. DPO:把 RL 变回监督学习
DPO(2023,副标题 Your Language Model is Secretly a Reward Model )的关键推导:带 KL 约束的最优策略有闭式解 π*(y|x) ∝ π_ref(y|x) · exp(r(x,y)/β),反过来 reward 可以用策略表示:r = β · log(π/π_ref) + const。代入 Bradley-Terry:
loss = -log σ( β·[ log(π_θ(y_w)/π_ref(y_w)) - log(π_θ(y_l)/π_ref(y_l)) ] )
直觉:相对于 reference,提高 chosen 的概率、压低 rejected 的概率 ,β 控制能偏离多远。
🧩 难点拆解:DPO 为什么不需要 reward model
先直觉 :推导链只有 3 步——
在「多拿 reward、但 KL 不能太大」这个目标下,数学上可以证明最优策略 长这样:π*(y|x) ∝ π_ref(y|x) · exp(r(x,y)/β),即在 reference 的基础上,reward 越高的回答概率被放大得越多。
这个关系是一一对应的,可以反解:r(x,y) = β · log(π*(y|x)/π_ref(y|x)) + β·log Z(x)。Z(x) 是一个只和 prompt 有关的归一化常数,很难算。
Bradley-Terry 只用同一个 prompt 下 两个回答的分差 ,β·log Z(x) 两边一样,相减直接抵消。于是「训练 RM → 用 RL 找最优策略」这两步可以合并成:直接训练 policy,让它隐含的 reward 满足偏好。
类比:与其先训一个「裁判」再让选手照着裁判练,不如直接让选手在「谁赢谁输」的记录上学——选手自己的概率变化本身就定义了一个裁判。
再代数字 (β = 0.1):
chosen:log π_θ = −10,log π_ref = −12 → 隐式 reward = 0.1 × (+2) = +0.2
rejected:log π_θ = −15,log π_ref = −14 → 隐式 reward = 0.1 × (−1) = −0.1
margin = 0.3 → σ(0.3) ≈ 0.57 → loss ≈ 0.55。训练开始时 π_θ = π_ref,margin = 0,loss = ln 2 ≈ 0.69;梯度会继续推高 chosen、压低 rejected 相对 reference 的比值。
一句话总结 :在 KL 约束下 reward 和最优策略可以互相换算,prompt 相关的常数在成对比较里抵消,所以 policy 本身就能充当 reward model。
只需要 policy + reference 两个模型,不需要 RM、critic、在线生成。
训练像 SFT 一样简单:一个 batch 的偏好对,四次 log-prob 前向(其中 reference 的两次可以预先算好)。
代价:offline ——只从固定数据集学,不能探索模型自己生成的新回答;分布漂移后效果可能不如 online 方法。常见改进是 iterative/online DPO:每轮用当前模型重新采样、打分、构造新偏好对。
5. 三条路线对比
SFT
RLHF-PPO
DPO
数据
示范答案
偏好对 → RM
偏好对
模型数
1
4
2
在线生成
否
是
否
优点
简单
上限高、可探索
简单稳定、便宜
缺点
只能模仿
贵、难调、reward hacking
offline、易过拟合偏好数据
代码片段
DPO loss 的完整核心(和 TRL 的实现思路一致):
import torch
import torch.nn.functional as F
def seq_logprob(model, input_ids, labels): # input_ids/labels: [B, T]
logits = model(input_ids).logits[:, :-1] # [B, T-1, V],第 t 位预测第 t+1 个 token
lp = torch.log_softmax(logits, -1) # 转成 log 概率
tgt = labels[:, 1:] # [B, T-1],右移一位对齐(上一课的「错一位」)
tok_lp = lp.gather(-1, tgt.clamp(min=0).unsqueeze(-1)).squeeze(-1) # [B, T-1] 取出真实 token 的 log 概率;clamp 只为避免 -100 越界
return (tok_lp * (tgt != -100)).sum(-1) # [B] 只累加回复部分的 log-prob
def dpo_loss(policy, ref, chosen, rejected, beta=0.1):
pi_w = seq_logprob(policy, *chosen)
pi_l = seq_logprob(policy, *rejected)
with torch.no_grad(): # reference 冻结
ref_w = seq_logprob(ref, *chosen)
ref_l = seq_logprob(ref, *rejected)
# 隐式 reward = beta * log(pi / ref)
margin = beta * ((pi_w - ref_w) - (pi_l - ref_l))
return -F.logsigmoid(margin).mean() # Bradley-Terry
整个算法就这些:没有采样、没有 critic、没有 RM。
beta * (pi - ref) 被称为 implicit reward ,训练时可以监控 chosen/rejected 的 implicit reward 差距。
seq_logprob 复用了上一课的 loss mask(-100),只对回复 token 求和。
工程视角 / 真实世界
InstructGPT / ChatGPT :经典 PPO 路线,RM 为 6B。
Llama 3 :post-training 放弃 PPO,采用多轮「SFT + rejection sampling + DPO」,理由是 DPO 在大规模下计算更省、更稳定。
变体很多 :IPO、KTO(只需要点赞/点踩而非成对数据)、SimPO(去掉 reference)、ORPO(把 SFT 与偏好合并)等,本质都是在 DPO 的框架上改 loss。
框架 :HuggingFace TRL(DPOTrainer、PPOTrainer)、OpenRLHF、verl。PPO 的 4 模型放置与生成加速是下一课 RL infra 的核心。
RLHF ≠ 真正的 RL :Karpathy 强调 RM 只是人类偏好的一个可被「钻空子」的模拟器,RLHF 只能跑有限步,跑久了必然 reward hacking;而可验证奖励(数学、代码)没有这个问题——这就是下一课 RLVR 的出发点。
💬 能聊的要点
「PPO 版 RLHF 要 4 个模型:actor 和 critic 要训练,reward model 和 reference 冻结;KL 惩罚用来防 reward hacking,critic 用来算 advantage 降方差。」
「DPO 利用 KL 约束下最优策略的闭式解,把 reward 表示成 β·log(π/π_ref),从而把 RLHF 变成偏好对上的二分类 loss——只要 policy 和 reference 两个模型,不需要在线采样。」
「DPO 的代价是 offline,无法探索;PPO 等 online 方法上限更高但工程重。Llama 3 选了迭代式 DPO,而推理模型又回到了 online RL(GRPO)。」
常见误区
❌ DPO 完全不需要 reward ✅ DPO 仍然基于同一个 RLHF 目标,reward 被隐式地表示在 policy 与 reference 的 log-ratio 里。
❌ Reward model 分数越高越好,可以一直优化 ✅ RM 是不完美的代理,过度优化会 reward hacking,所以要 KL 约束和早停。
❌ PPO 的 reference model 和 critic 是同一个东西 ✅ reference 是冻结的 SFT 模型,只用来算 KL;critic 是可训练的 value 模型,用来估计期望回报。
自测(心算/口答即可)
70B 模型做 PPO,actor/critic/RM/reference 都是 70B,仅 BF16 权重就要多少显存?答案 4 × 70e9 × 2 B = 560 GB;actor 和 critic 还要加上梯度和 optimizer state(各约 16 B/参数),总计约 2.5 TB(actor、critic 各按 16 B/参数算训练状态:2 × 70e9 × 16 B ≈ 2.24 TB;reference、reward 只存 BF16 权重:2 × 140 GB = 280 GB)。
DPO 训练中 chosen 和 rejected 的 log-prob 都在下降,但 margin 在增大,这是否可能?答案 可能,而且很常见:loss 只约束两者的相对差,rejected 降得更快即可。这也是 DPO 的一个已知副作用。
为什么说 RLHF 不能像 AlphaGo 那样一直跑下去?答案 奖励来自一个神经网络 RM,它对分布外的回答打分不可靠,模型会找到对抗样本式的漏洞;围棋的胜负是可验证的真实奖励,不能被钻空子。
延伸
🎬 Karpathy:Deep Dive into LLMs like ChatGPT 的「reinforcement learning from human feedback (RLHF)」一节(02:48:26 起)
🎬 李沐:「InstructGPT」论文精读;「Llama 3.1论文精读 · 5. 模型训练过程」(DPO 部分)
📄 Ouyang et al., Training language models to follow instructions with human feedback (2022);Rafailov et al., Direct Preference Optimization: Your Language Model is Secretly a Reward Model (2023);Schulman et al., Proximal Policy Optimization Algorithms (2017)
下一课 → 33 · 推理模型:GRPO、RLVR 与 test-time compute
33 · 推理模型:GRPO、RLVR 与 test-time compute(o1 / DeepSeek-R1)
⏱ 10 分钟 | 前置:32 · RLHF、PPO 与 DPO | 难度:★★★
一句话
推理模型 = 在数学、代码这类可自动验证答案 的任务上做大规模 online RL(RLVR),GRPO 用「同一题采样一组答案、组内比较」代替 critic 来算 advantage;模型自发学会写长长的 chain-of-thought(CoT,写在最终答案之前的逐步推理过程),于是「推理时多花 token」本身成了一个新的 scaling 维度。
为什么要关心
2024 年 9 月 OpenAI o1、2025 年 1 月 DeepSeek-R1 之后,「RL 后训练」成为各家投入最大的环节之一。它对 infra 的冲击也最大:一次 RL step 的大部分时间花在 rollout(让当前模型对一批题目生成回答 = 大批量推理) 上,训练框架里必须嵌一个 vLLM/SGLang。这使 RL infra 成了今天 AI Infra 岗位最热的方向之一。
核心直觉
1. RLVR:用规则代替 Reward Model
上一课说 RLHF 的 RM 会被钻空子,所以不能长跑。如果奖励是可验证的 呢?
数学题: 抽出 \boxed{} 里的答案,和标准答案比对 → 1 / 0
代码题: 在沙箱里跑单元测试 → 通过率
格式: 是否用 <think>...</think> 包住思考过程 → 1 / 0
这叫 RLVR(Reinforcement Learning with Verifiable Rewards,名字来自 AI2 的 Tülu 3)。规则奖励很难被 hack,所以可以像 AlphaGo 那样一直跑下去 ,模型能力随 RL 算力持续提升。
2. GRPO:没有 critic 的 PPO
GRPO(Group Relative Policy Optimization)最早出自 DeepSeekMath(2024)。PPO 需要一个和 actor 同规模的 critic 来估计 baseline;GRPO 的想法是:同一道题采样 G 个回答,用组内平均分当 baseline 。
🧩 难点拆解:GRPO 的 group advantage
先直觉 :就是考试「按班级排名」而不是「按绝对分」评价。PPO 的 critic 相当于给每道题预估 一个「正常水平」分数,再看这次超出多少;GRPO 干脆让模型对同一道题连考 G 次 ,用这 G 次的平均分当「正常水平」——不用额外训练一个预估模型。
再代数字 (G = 4,下面用总体标准差):
prompt: "求 1+2+...+100"
y1 → 5050 ✔ r=1 mean = 0.5, std = 0.5
y2 → 5000 ✘ r=0 A1 = (1-0.5)/0.5 = +1 ← 鼓励
y3 → 5050 ✔ r=1 A2 = (0-0.5)/0.5 = -1 ← 抑制
y4 → 4950 ✘ r=0 ...
A_i = (r_i - mean(r)) / std(r):减均值 → 比同组平均好的为正、差的为负;除以 std → 不同题目的 advantage 量级统一(有的题分差大、有的小)。
部分得分的代码题,4 个回答单测通过率 [0.2, 0.4, 0.9, 0.5]:mean = 0.5,std ≈ 0.255 → A ≈ [-1.18, -0.39, +1.57, 0]。第 4 个恰好等于平均,不奖不罚。
一组全对 [1,1,1,1] 或全错 [0,0,0,0]:每个 r_i - mean = 0 → A 全为 0 → 零梯度。
得到 A_i 后,回答 i 的每一个 token 都用同一个 A_i:y1 里所有 token 概率一起被推高,哪怕其中某一步写得很啰嗦。这就是「信用分配(credit assignment:把最终成败归功/归咎到具体哪一步)粗糙」的含义。
其余沿用 PPO 的 clipped objective,KL 惩罚直接加在 loss 上(相对 reference)。
一句话总结 :GRPO 用「同题多次作答的组内平均」代替 critic 当基线,组内标准化得到 advantage,简单、省显存,代价是要多采样、且整条回答共享一个信号。
相比 PPO:少一个 critic (省显存、省训练),也不需要训 RM。
一组全对或全错时 advantage 全为 0,这组样本不产生梯度——太难或太简单的题都是浪费,所以题目难度的筛选很关键(DAPO 的 dynamic sampling 就是过滤掉这类组)。
3. DeepSeek-R1-Zero 与 R1 的故事
R1-Zero :直接在 DeepSeek-V3-Base 上做 GRPO,不经过 SFT ,奖励只有「答案对不对 + 格式对不对」。
AIME 2024(美国数学邀请赛,高难度竞赛题)pass@1(采一次答对的比例)从 15.6% 涨到 71.0%,多数投票(majority voting:采样多次、取出现最多的答案)达到 86.7%。
回答长度随训练自然增长——没人教它「多想」,它自己发现更长的思考更容易拿到奖励。
出现了论文所说的「aha moment」:模型在思考中途写出类似「等等,我重新检查一下」的反思。
问题:可读性差、中英混杂。
R1 用四阶段流水线修复这些问题:
① Cold start SFT:几千条高质量长 CoT 数据,给模型一个可读的起点
② Reasoning RL:GRPO + 规则奖励(外加语言一致性奖励)
③ Rejection sampling + SFT:用②的模型生成约 60 万推理样本 + 约 20 万非推理样本,重新 SFT
④ 全场景 RL:推理题用规则奖励,通用任务用 RM(有用性、无害性)
另一个结论:把 R1 的输出蒸馏 (distillation:用大模型生成的回答当 SFT 数据训练小模型)到 Qwen/Llama 小模型,效果好于直接在小模型上做 RL。
4. Test-time compute:推理时的 scaling law
o1 的博客给出两条曲线:性能随训练时 RL 算力 和推理时思考算力 都平滑提升。推理时多花算力的方式:
更长的 CoT :一次生成里多想(R1/o1 的主要方式),Karpathy 称之为「models need tokens to think」——每个 token 的计算量是固定的,难题需要摊到更多 token 上。
并行采样 :best-of-N、majority voting、配合 verifier 选答案。
对推理服务的直接后果:输出从几百 token 变成几千上万 token,decode 阶段和 KV Cache 压力大增——这把我们引向 Part 5。
代码片段
GRPO 的 advantage 和 loss 核心(伪代码,省略 KL 与 padding 细节):
# prompts: [B],每个 prompt 采样 G 个回答
responses = rollout_engine.generate(prompts, n=G, temperature=1.0) # vLLM/SGLang
rewards = torch.tensor([verify(p, r) for p, r in zip(prompts_rep, responses)])
rewards = rewards.view(B, G)
# Group-relative advantage:组内标准化,代替 critic(torch.std 默认是样本标准差,上例会得到 ±0.87,方向不变)
adv = (rewards - rewards.mean(1, keepdim=True)) / (rewards.std(1, keepdim=True) + 1e-6)
adv = adv.view(-1, 1) # 每个回答的所有 token 共享同一个 advantage
old_logp = logprobs(policy_old, responses).detach() # 生成时那版模型的 log-prob,作为 ratio 的分母
for _ in range(ppo_epochs): # 同一批 rollout 复用几次,摊薄生成成本
logp = logprobs(policy, responses) # [B*G, T]
ratio = torch.exp(logp - old_logp) # 新/旧策略对同一 token 的概率比
loss = -torch.min(ratio * adv, ratio.clamp(1 - eps, 1 + eps) * adv) # PPO clip,eps≈0.2
loss = (loss * mask).sum() / mask.sum() # + beta * KL(policy || ref)
loss.backward(); opt.step(); opt.zero_grad()
rollout_engine.load_weights(policy.state_dict()) # 把新权重同步给推理引擎
第一行 generate 就是一次完整的大批量推理,G 常取 8–64,每条可能上万 token。
没有 value model;整条回答共享一个 advantage,简单但信用分配粗糙。
最后一行「权重同步」在千亿参数规模下是个真实的工程问题(训练并行布局 ≠ 推理并行布局,需要 reshard)。
工程视角 / 真实世界:为什么 RL 是 infra 问题
Rollout 主导耗时 :RL step 大部分时间在生成;而回答长度呈长尾分布,一个 batch 要等最长的那条生成完,GPU 大量空转。对策:continuous batching(第 39 课)、partial rollout(超长的回答先截断、下一轮接着生成)、异步 RL (生成和训练流水线化,容忍少量 off-policy:训练用的数据由「稍旧一版」模型生成)。
两套并行布局 :训练用 FSDP/Megatron(TP/PP/DP),推理用 vLLM/SGLang(TP/EP),同一份权重每步都要在两种布局间转换。
放置策略 :colocate(训练和推理共用同一批 GPU、分时切换,显存要 offload/onload)vs disaggregated(分开两批 GPU,要跨机传权重)。
框架 :verl (字节 Seed 开源,HybridFlow 论文,单控制器编排 + 训练/推理引擎混合);OpenRLHF (Ray + vLLM + DeepSpeed);TRL 也提供 GRPOTrainer。
奖励侧 :代码题需要大规模沙箱执行,数学题需要鲁棒的答案抽取与等价判断——这些都是后端工程师很熟悉的服务化问题。
💬 能聊的要点
「GRPO 去掉了 PPO 的 critic:同一个 prompt 采 G 个回答,用组内 reward 的均值和标准差做归一化作为 advantage;配合可验证的规则奖励(RLVR),既不需要 value model 也不需要 reward model。」
「R1-Zero 证明了纯 RL、不经 SFT 就能涌现长 CoT 和自我反思,AIME pass@1 从 15.6% 到 71%;R1 再用 cold-start SFT、拒绝采样和多阶段 RL 解决可读性问题。」
「RL 训练本质是『推理 + 训练』的混合负载:rollout 占大头、长度长尾、每步要在训练和推理两种并行布局间同步权重,所以 verl/OpenRLHF 这类框架的核心难点都在 infra。」
常见误区
❌ GRPO 完全不需要 reference model ✅ GRPO 去掉的是 critic;标准 GRPO 仍用 reference 计算 KL 惩罚(部分后续工作如 DAPO 选择去掉 KL)。
❌ 推理模型的长思考是 SFT 教出来的 ✅ R1-Zero 的长 CoT 是在只有结果奖励的 RL 中自发涌现的;SFT 只用于冷启动和可读性。
❌ Test-time compute 就是调大 max_tokens ✅ 模型要经过 RL 训练才会有效利用更多 token;普通模型给更长的预算往往只是废话更多。
自测(心算/口答即可)
GRPO 中一组 8 个回答全部答对,这组对梯度的贡献是多少?说明什么?答案 advantage 全为 0,贡献为 0(KL 项除外)。说明题太简单,训练数据需要按难度筛选或动态过滤。
相比 PPO,GRPO 在显存上省掉了什么?代价是什么?答案 省掉了与 actor 同规模的 critic(参数、梯度、optimizer state)。代价是每个 prompt 要采样多个回答(rollout 量增加),且整条回答共享一个 advantage,信用分配更粗。
为什么 RLHF 不能长跑,而 RLVR 可以?答案 RLHF 的奖励来自可被 hack 的神经网络 RM;RLVR 的奖励来自规则验证(答案比对、单元测试),很难被钻空子。
延伸
🎬 Karpathy:Deep Dive into LLMs like ChatGPT 的「models need tokens to think」(01:46:56)、「reinforcement learning」(02:14:42)、「DeepSeek-R1」(02:27:47)、「AlphaGo」(02:42:07)几节
🎬 李沐:暂无对应论文精读;可回看「InstructGPT」对 PPO 流程的讲解作为铺垫
📄 DeepSeek-AI, DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning (2025);Shao et al., DeepSeekMath (2024, GRPO 出处);Sheng et al., HybridFlow: A Flexible and Efficient RLHF Framework (verl, 2024)
下一课 → 34 · LoRA 与参数高效微调
34 · LoRA 与参数高效微调(PEFT)
⏱ 10 分钟 | 前置:33 · 推理模型 | 难度:★★☆
一句话
LoRA(Low-Rank Adaptation)冻结原权重 W,只训练一个低秩增量 ΔW = B·A(秩 r 通常 8–64),可训练参数降到原模型的 1% 以下(常见千分之几),梯度和 optimizer state 随之消失;QLoRA 再把冻结的 W 压到 4 bit,让单卡微调几十 B 模型成为可能;推理时一个 base model 可以同时挂上百个 LoRA adapter(训练出的那对小矩阵 A、B,可以像插件一样挂载/卸下)服务不同租户。
为什么要关心
第 28 课算过:7B 全参微调要约 112GB 模型状态,而且每个任务都产出一份 14GB 的完整权重。LoRA 让「每个客户/任务一个定制模型」在训练成本和存储/服务成本上都变得可行。它也是 infra 话题:multi-LoRA serving 是 vLLM/SGLang 的正式功能,需要专门的 kernel 和调度。
核心直觉
1. 低秩增量
对任意一个 Linear 层 h = W·x(W 形状 d×k:输入 k 维、输出 d 维,例如 attention 的 q_proj 是 4096×4096):
┌──────────────┐
x ────────────► │ W (d×k) 冻结 │ ───────────┐
│ └──────────────┘ ▼
│ ┌─────────┐ ┌─────────┐ (+) ──► h
└───► │ A (r×k) │ ─► │ B (d×r) │ ─× α/r ──┘
└─────────┘ └─────────┘
随机初始化 初始化为 0
h = W·x + (α/r)·B·A·x
B 初始化为 0 :训练开始时 ΔW=0,模型和原来完全一样,训练平稳起步。
α/r 缩放 :让不同 r 下的更新幅度大致可比,换 r 时不必大幅重调学习率。
为什么低秩就够?LoRA 论文的假设:微调时权重的变化量 具有很低的「intrinsic rank」(内在秩:真正需要的独立方向数)——你只是在调整方向,而不是重学一个模型。
🧩 难点拆解:rank 是什么,B 为什么从 0 开始
先直觉(rank) :一个矩阵的 rank(秩)= 它里面「真正独立的行/列方向」有几个。rank 1 的矩阵就是两个向量的外积:每一行都是同一行的倍数,像一张「每行只是整体放大缩小」的报表,存两个向量就够了。rank r 的矩阵 = r 个这样的外积相加。B·A(d×r 乘 r×k)最多只有 rank r,所以 LoRA 等于说:「微调带来的变化,用 r 个方向就描述得差不多了」。
小例子(rank) :
[1 2 3] [1]
[2 4 6] = [2] × [1 2 3] 9 个数,只需存 3+3=6 个
[3 6 9] [3]
放大到 4096×4096、r = 16:存 4096×16 + 16×4096 ≈ 131K 个数,而不是 16.8M——省 128 倍。r 越大表达力越强,参数也线性增加。
先直觉(零初始化) :微调的起点应该恰好是原模型 ,否则第一步就在一个被随机噪声污染的模型上训练。B = 0 让 ΔW = B·A = 0,起点不变;但如果 A、B 都 是 0,梯度也会全是 0,永远学不动——所以只能一个为 0、另一个随机。
小例子(零初始化,全部用标量) :h = W·x + B·A·x,取 x = 1,A = 0.5(随机值),B = 0,上游传回的梯度 ∂L/∂h = g:
输出 h = W + 0 = W,和原模型完全一样。
∂L/∂B = g · A · x = 0.5g ≠ 0 → B 第一步就动起来 。
∂L/∂A = g · B · x = 0 → A 第一步不动;等 B 变成非零后,A 的梯度也就有了。
若 A = B = 0:两个梯度都是 0,卡死在原点。若 B 也随机:起点就多了一个随机 ΔW,原模型能力被打乱。
一句话总结 :rank r 决定「允许改动几个方向」;B=0、A 随机让起点等于原模型、同时保证梯度能从 B 开始流动。
2. 参数量和显存账
以一个 4096×4096 的投影矩阵为例:
全量: 4096 × 4096 = 16.8M 参数
LoRA r=16: 4096×16 + 16×4096 = 131K 参数(约 0.8%)
7B 模型全参微调 vs LoRA(BF16 base,所有 Linear 挂 r=16,LoRA 参数约几十 M):
全参(第 28 课口径)
LoRA
Base 权重
16 B/param 全套 ≈ 112 GB
2 B/param(冻结,只存 BF16)≈ 14 GB
可训练部分
—
几十 M 参数 × 16 B ≈ 1 GB 以内
产出 checkpoint
14 GB
几十 MB
Activations 仍然存在(反向需要穿过冻结层算输入梯度),但模型状态从 112GB 降到约 15GB。LoRA 论文在 GPT-3 175B 上报告:可训练参数减少约 10,000 倍、训练显存从 1.2TB 降到 350GB、checkpoint 从 350GB 降到约 35MB。
3. 推理:merge 或不 merge
Merge :W' = W + (α/r)·B·A 合进原权重,推理结构不变、零额外延迟 。适合单一任务部署。
不 merge :保持 base 与 adapter 分离,按请求动态挂载——这就是 multi-LoRA serving。
4. QLoRA:把冻结的 base 压到 4 bit
QLoRA(2023)三件套:
NF4 (4-bit NormalFloat):针对正态分布权重设计的 4 bit 量化格式(量化 = 用更少的 bit 近似存数字,4 bit 只能表示 16 个取值;NF4 让这 16 个值在「权重最密集的 0 附近」排得更密),base 权重以 4 bit 存储,前向时逐块反量化(dequantize:还原成近似的 BF16)后计算。
Double quantization :连量化用的 scale 也再量化一次,进一步省显存。
Paged optimizers :显存尖峰时把 optimizer state 换出到 CPU 内存(借用 NVIDIA unified memory,类似 OS 的 swap)。
效果:65B 模型能在单张 48GB GPU 上微调。梯度只流向 BF16 的 LoRA 参数,4-bit base 始终冻结。代价是反量化带来额外计算,训练更慢。
5. 其他 PEFT 方法(一句话认识)
Adapter (2019):在层间插入小 MLP,推理有额外延迟。
Prefix / Prompt Tuning :只训练拼在输入前的若干「虚拟 token」向量。
DoRA :把权重拆成幅度与方向,只对方向做 LoRA。
LoRA 能胜出,关键是可 merge、零推理开销 ,且实现简单。
代码片段
一个最小的 LoRA Linear(与 HuggingFace PEFT 的思路一致):
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, r=16, alpha=32):
super().__init__()
self.base = base
self.base.weight.requires_grad_(False) # 冻结原权重
d_out, d_in = base.weight.shape
self.A = nn.Parameter(torch.randn(r, d_in) * 0.01)
self.B = nn.Parameter(torch.zeros(d_out, r)) # 零初始化 → ΔW=0
self.scale = alpha / r # 例:alpha=32, r=16 → 2.0
def forward(self, x): # x: [..., d_in]
return self.base(x) + (x @ self.A.T @ self.B.T) * self.scale # [..., r] → [..., d_out]
# 先乘 A 降到 r 维,再乘 B 升回:永远不物化 d_out×d_in 的 ΔW
def merge(self): # 部署时合并,零额外延迟
self.base.weight.data += (self.B @ self.A) * self.scale
优化器只看到 A、B:AdamW([p for p in model.parameters() if p.requires_grad]),optimizer state 随之缩小上千倍。
x @ A.T @ B.T 的顺序很重要:先降维再升维,计算量是 O(r·(d_in+d_out)) 而不是 O(d_in·d_out)。
实践中常对所有 Linear(q/k/v/o 和 MLP 的 gate/up/down)挂 LoRA,而非只挂 attention。
工程视角 / 真实世界
训练工具 :HuggingFace PEFT(LoraConfig(r, lora_alpha, target_modules))、bitsandbytes(NF4)、LLaMA-Factory、Unsloth 等。
Multi-LoRA serving :S-LoRA、Punica 提出把「不同请求用不同 adapter」放进同一 batch:base 部分照常做一个大 GEMM,LoRA 部分用专门的 kernel(GPU 上的一个计算函数;Punica 的 SGMV = Segmented Gather Matrix-Vector,把 batch 按 adapter 分段、每段乘自己的 A/B)按 adapter 分段计算。vLLM 用 --enable-lora --max-loras N 开启,请求里指定 adapter 名;adapter 在 GPU/CPU 间按需换入换出,本质和 KV Cache 一样是缓存管理问题。
LoRA 与 RL :Thinking Machines 的博客 LoRA Without Regret (2025)报告,在 RL 后训练中即使 rank 很低 LoRA 也能追平全参;在数据量较小的 SFT 中也接近全参,但前提是给所有层挂 LoRA、学习率约取全参的 10 倍。
局限 :大规模继续预训练、注入大量新知识时,LoRA 容量不足,通常不如全参。
💬 能聊的要点
「LoRA 冻结 W,只训 ΔW=B·A,B 零初始化保证起点等于原模型;显存上省掉的是 base 参数的梯度和 Adam 状态——7B 从 16 B/param 的约 112GB 降到 BF16 权重的 14GB 左右。」
「QLoRA 把冻结的 base 用 NF4 存成 4 bit、前向时反量化成 BF16 计算,梯度只流向 LoRA;65B 模型可以在单张 48GB 卡上微调。」
「LoRA 可以 merge 回原权重做到零推理开销,也可以不 merge 做 multi-LoRA serving:一个 base 服务上百个 adapter,base 做共享 GEMM,adapter 部分用 SGMV 类 kernel 分段计算,vLLM/SGLang 都已支持。」
常见误区
❌ LoRA 能大幅减少 activation 显存 ✅ LoRA 省的主要是梯度和 optimizer state;activation 仍需要为反向保存,长序列时仍可能是瓶颈。
❌ LoRA 推理一定比原模型慢 ✅ Merge 后结构与原模型完全相同,零额外开销;只有不 merge 的 multi-LoRA 模式才有额外的小矩阵计算。
❌ QLoRA 是在 4 bit 上做训练计算 ✅ 4 bit 只是存储格式,计算时反量化成 BF16;可训练的 LoRA 参数本身也是 BF16/FP32。
自测(心算/口答即可)
一个 8192×8192 的矩阵挂 r=8 的 LoRA,可训练参数多少?占原矩阵多少比例?答案 2×8192×8 = 131,072,约 0.2%(原矩阵约 67M)。
为什么 B 要零初始化而 A 随机初始化?两个都零初始化会怎样?答案 B=0 保证起始 ΔW=0、不破坏原模型;A 随机保证梯度能流动。两者都为 0 时,A 和 B 的梯度都为 0,永远学不动。
70B 模型用 QLoRA,base 权重大约占多少显存?答案 4 bit ≈ 0.5 B/param,约 35 GB,再加少量量化常数和 LoRA 参数/状态,加上 activation,单张 80GB 卡可行。
延伸
🎬 Karpathy:暂无专门讲 LoRA 的视频;Deep Dive into LLMs like ChatGPT 中「pretraining to post-training」一节有助于理解「微调只需要小改动」的背景
🎬 李沐:zh.d2l.ai 第 13.2 节「微调」(计算机视觉章节里讲 fine-tuning 的一般思路)
📄 Hu et al., LoRA: Low-Rank Adaptation of Large Language Models (2021);Dettmers et al., QLoRA: Efficient Finetuning of Quantized LLMs (2023);Sheng et al., S-LoRA: Serving Thousands of Concurrent LoRA Adapters (2023)
下一课 → 34a · Evaluation:Perplexity、Benchmark、污染与 LLM-as-a-Judge
34a · Evaluation:Perplexity、Benchmark、污染与 LLM-as-a-Judge
⏱ 10 分钟 | 前置:33 · 推理模型 | 难度:★★☆ | 🆕 对标 Stanford CS336 Lecture 12(Evaluation)
一句话
Evaluation 回答「模型到底变好了没有」,分三层:内在指标 (loss / perplexity,便宜、连续,适合训练中盯盘)、Benchmark (选择题、可自动验证的数学/代码题、开放式对话的人评或 LLM 评),以及线上真实流量 。每一层都有坑:perplexity 跨 tokenizer 不可比,benchmark 会被污染、会饱和、对 prompt 格式敏感,小题集的分差往往在噪声之内。
为什么要关心
每次新模型发布都附一张分数表。看懂「MMLU 5-shot」「pass@1 avg@16」「LC win rate」这些后缀,才能判断两个数字能不能放在一起比。
数据配比(第 27 课)、scaling 实验(第 18a 课)、RL 奖励(第 33 课)最终都要靠 eval 拍板;eval 选错,整个团队会朝错的方向优化。
对 infra 来说,eval 是一类很重的推理负载:推理模型的一次完整评测要生成上千万甚至上亿 token,跑在 vLLM/SGLang 上,还要求可复现。
核心直觉
1. 内在指标:Perplexity 与 bits-per-byte
Perplexity(困惑度) = exp(平均每 token 的 cross-entropy)(第 08 课)。loss = 2.0 nats 时 perplexity ≈ 7.4,可以读成「模型平均在 7.4 个候选里犹豫」。它连续、便宜、和训练目标一致,是训练中盯盘的首选。
坑在于它按 token 计 :tokenizer 不同,同一段文本的 token 数不同,per-token loss 无法直接比。解法是换成和 tokenizer 无关的单位 bits-per-byte :
bits-per-byte = 总 NLL(nats)/ ln 2 / 原文字节数
= 每 token loss × token 数 / (0.693 × 字节数)
例:英文约 4 字节/token,loss 2.0 nats/token → 2.0 / 0.693 / 4 ≈ 0.72 bits/byte。perplexity 低不等于好用:base model 的 perplexity 可以很好,但不会听指令(第 31 课)。
2. 选择题 Benchmark:同一个模型,打分方式不同分数就不同
MMLU :57 个学科、约 1.4 万道四选一,随机猜 25%。看起来简单,打分方式至少有两种:
Log-likelihood 法 :把每个选项接在题目后面,算模型给这个选项的对数概率,取最大者(lm-evaluation-harness 对 base model 的默认做法,见下方代码)。不需要模型会「回答」,适合评 base model。
生成法 :让模型自由作答,再用正则抽出「答案是 B」。chat / 推理模型通常这样评,但抽取规则写得不好会误判。
再加上 0-shot / 5-shot、有没有 CoT、prompt 模板的细节,同一个模型在「同一个 benchmark」上相差几个点很常见。所以只比同一套 harness、同一设置下的数字 。
Benchmark 还会饱和 :前沿模型在 MMLU 上已超过 90%,区分度消失,于是出现更难的替代品:MMLU-Pro(10 个选项)、GPQA Diamond(198 道研究生水平科学题,题目设计成「Google 也搜不到」)、Humanity's Last Exam 等。
3. 可验证的生成题:数学、代码、Agent
数学 :GSM8K(小学应用题,已饱和)→ MATH → AIME(每年 30 题,题少、方差大)。答案是数字,可以自动比对。
代码 :HumanEval(164 题,函数级)→ LiveCodeBench(按发布日期持续收新题)→ SWE-bench Verified(500 个人工核验过的真实 GitHub issue,要求改仓库让测试通过)。
Agent :Terminal-Bench、τ-bench 一类,让模型在沙箱里多轮调用工具完成任务。评测本身就是一个「起容器、跑多轮、收结果」的分布式系统。
代码题常报 pass@k :每题采样 k 个答案,至少一个通过测试的概率。实际做法是每题采 n ≥ k 个、数出 c 个通过,用无偏估计 pass@k = 1 − C(n−c, k) / C(n, k)(HumanEval 论文提出,代码见下)。推理模型常报 pass@1 avg@16 :每题采 16 次、取平均正确率,用来压噪声。这正好就是 GRPO 的「组内采样」(第 33 课),所以 RL 训练里的 reward 曲线和 eval 分数天然相关。
4. 开放式对话:人评与 LLM-as-a-Judge
「写一封得体的拒绝邮件」没有标准答案,只能比较:
Chatbot Arena(LMArena) :真人提问,两个匿名模型作答,人投票选更好的,用 Bradley-Terry / Elo 模型汇总成排名。最接近真实偏好,但慢、贵,也会被「刷风格」(markdown、长度、emoji)影响。
LLM-as-a-Judge :让强模型当评委(MT-Bench、AlpacaEval、Arena-Hard)。便宜、可重复,和人评相关性不错,但有已知偏差:位置偏差 (偏好先出现的答案,要交换顺序评两次)、长度偏差 (偏好更长的回答,AlpacaEval 2 因此出了 length-controlled 版本)、自我偏好 (偏好同家族模型的回答)。
5. 污染(Contamination)与 Goodhart 定律
Benchmark 题目会出现在网上,进而被爬进预训练数据(第 27 课)。模型「背过」原题,分数就失去意义。常见对策:
检测 :训练数据与测试集做 n-gram 重叠检查(GPT-3 论文用 13-gram),对重叠题单独报分;
动态题库 :LiveCodeBench、AIME 这类按日期出新题,只评模型训练截止日期之后的题;
私有测试集 :题目不公开,只提供评测服务。研究者做过「重新出一套同分布的新题」的实验(如 GSM1k 对照 GSM8K),部分模型在新题上明显掉分。
更深一层是 Goodhart 定律:一个指标一旦成为优化目标,它就不再是好指标。团队盯着某个榜单调数据配比,分数会上涨,真实能力未必。所以严肃团队都维护内部 held-out 评测集 ,并且最终看线上指标。
🧩 难点拆解:这 1 分差距是真的吗?
直觉 :benchmark 分数是对「模型真实正确率 p」的一次抽样估计,题目越少,估计越抖。就像用 30 个用户的 A/B 测试去下结论。
代入数字 :正确率的标准误 SE = √(p(1−p)/n),95% 置信区间约 ±2 SE。
Benchmark 题数 n 假设 p SE ±2 SE
AIME(一年) 30 50% 9.1 分 ±18 分
GPQA Diamond 198 50% 3.6 分 ±7 分
SWE-bench Verified 500 60% 2.2 分 ±4.4 分
MMLU 约 14,000 80% 0.34 分 ±0.7 分
AIME 上 73.3 vs 70.0 只差 1 道题(30 题里 22 道 vs 21 道),远在噪声内;
每题采 16 次取平均(avg@16)能压掉「采样随机性」那部分噪声,但压不掉「只有 30 道题」这部分;
所以小题集上要看多次采样均值,最好再跨多个 benchmark 交叉验证;MMLU 上 1 分差距则大概率是真的。
一句话 :先看题数再看分差;n 小于几百时,几个点的差距不足以下结论。
代码片段
import torch
from math import comb
# 1) 选择题的 log-likelihood 打分:哪个选项的概率最高就选哪个
def choice_logprob(model, tok, prompt, choice):
p = tok(prompt, return_tensors="pt").input_ids
c = tok(choice, add_special_tokens=False, return_tensors="pt").input_ids
ids = torch.cat([p, c], dim=1)
logp = model(ids).logits[0, :-1].log_softmax(-1) # 位置 t 预测 t+1(第 05 课)
tgt = ids[0, 1:]
lp = logp[torch.arange(len(tgt)), tgt]
return lp[-c.shape[1]:].sum().item() # 只累加选项部分的 log 概率
prompt = "Q: 水在标准大气压下的沸点是多少摄氏度?\nA:"
scores = {ch: choice_logprob(model, tok, prompt, ch) for ch in [" 90", " 100", " 120"]}
pred = max(scores, key=scores.get)
# 2) pass@k 的无偏估计:每题采 n 个,c 个通过
def pass_at_k(n, c, k):
if n - c < k:
return 1.0
return 1 - comb(n - c, k) / comb(n, k)
print(pass_at_k(20, 3, 1), pass_at_k(20, 3, 10)) # 0.15, 0.89
log-likelihood 法只需要一次 forward,不需要生成,所以评 base model 非常快;但选项长度不同会有偏差,harness 常提供按长度归一化的变体(acc_norm)。
pass@k 不能用「采 k 次看有没有过」来估,方差太大;采 n 个再用组合数公式是无偏的。
同一题 c=3/20:pass@1 只有 15%,pass@10 高达 89%。报告里如果只给 pass@k 而不说 k,数字没有意义。
工程视角 / 真实世界
常用 harness :EleutherAI lm-evaluation-harness(base model 评测事实标准)、Hugging Face lighteval、OpenAI simple-evals、UK AISI 的 Inspect(Agent / 安全评测)、Stanford HELM(强调多指标、全场景,出自 CS336 讲师 Percy Liang 的团队)。
Eval 是推理负载 :一个推理模型在 AIME 上做 avg@16,30 题 × 16 次 × 最长 32K tokens,单个 benchmark 就可能生成上千万 token;完整评测套件通常交给 vLLM/SGLang 离线批跑,吞吐优化(第 39、47 课)直接决定评测周期。
可复现性 :temperature、top-p、max tokens、chat template(第 31 课)都要固定并写进报告。即使 temperature = 0,batch 组成不同也可能让浮点累加顺序变化、输出不同;2025 年 Thinking Machines 的「batch-invariant kernels」工作专门处理这个问题。
训练中的 eval :预训练阶段每隔若干步跑 val loss 和一个小而稳的 benchmark 套件;小模型上很多能力题接近随机,常改用「正确答案的 log 概率」这类连续指标,曲线更平滑(第 18 课「FLOPs → NLL → accuracy」的两步拟合)。
💬 能聊的要点
「Perplexity 是 exp(cross-entropy),便宜又连续,适合训练中监控;但它按 token 计,换了 tokenizer 不可比,要换成 bits-per-byte。」
「同一个 benchmark,log-likelihood 打分和生成打分、几-shot、prompt 模板不同,结果能差好几个点,所以只比同一 harness 下的数字。AIME 只有 30 题,标准误约 9 分,1–2 题的差距没有统计意义。」
「LLM-as-a-Judge 便宜可重复,但有位置、长度、自我偏好三种偏差;benchmark 有污染和 Goodhart 问题,严肃团队都靠内部 held-out 集和线上指标兜底。」
常见误区
❌ Perplexity 越低,模型越好用。✅ 它衡量的是「续写互联网文本」的能力;指令遵循、推理、安全要靠 benchmark 和人评。
❌ 榜单上高 1 分就是更强。✅ 先看题数和评测设置;小题集上 1 分常常只是 1 道题,且设置不同的数字不可比。
❌ temperature = 0 就一定可复现。✅ batch 组成、kernel 实现、并行方式都可能改变浮点累加顺序;可复现需要固定版本、设置,甚至专门的 batch-invariant kernel。
自测(心算/口答即可)
模型 A 用 32K 词表、B 用 128K 词表,A 的 per-token loss 更低,能说明 A 更好吗?答案 不能。词表越大,每个 token 平均覆盖的文本越多,per-token loss 天然更高。要换算成 bits-per-byte(总 NLL ÷ ln2 ÷ 原文字节数)再比。
GPQA Diamond(198 题),模型 A 62%、模型 B 58%,差距显著吗?答案 SE ≈ √(0.6×0.4/198) ≈ 3.5 分,两者之差的标准误约 3.5 × √2 ≈ 4.9 分,4 分差距在 1 个标准误以内,不能下结论。
用 GPT 类模型当评委比较两个回答,至少要做哪两件事来减少偏差?答案 ① 交换两个回答的顺序各评一次(消除位置偏差);② 控制或校正长度(如 length-controlled win rate),必要时避免让同家族模型当评委。
延伸
🎓 Stanford CS336:Lecture 12(Evaluation)——见附录 CS336 对照与动手路线
🎬 Karpathy:Deep Dive into LLMs like ChatGPT 末尾关于如何跟踪模型进展(LMArena 等榜单)的部分
📄 Liang et al. 2022, Holistic Evaluation of Language Models (HELM,arXiv:2211.09110)
📄 Chen et al. 2021, Evaluating Large Language Models Trained on Code (HumanEval 与 pass@k,arXiv:2107.03374)
📄 Zheng et al. 2023, Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena (arXiv:2306.05685)
📄 Hendrycks et al. 2021, Measuring Massive Multitask Language Understanding (MMLU);Rein et al. 2023, GPQA ;Jimenez et al. 2024, SWE-bench
下一课 → 34b · 多模态:图像怎么变成 token,推理系统多了什么
34b · 多模态:图像怎么变成 token,推理系统多了什么
⏱ 10 分钟 | 前置:34a · Evaluation 、07 · Embedding | 难度:★★☆ | 🆕 对标 Stanford CS336 Lecture 17(Alignment, multimodality)
一句话
主流视觉语言模型(VLM)= Vision Encoder(ViT)+ Projector + LLM :把图片切成 14×14 像素的 patch,ViT 把每个 patch 编码成向量,Projector 映射到 LLM 的 embedding 空间,当成「图像 token」和文本 token 拼在一起送进 Transformer。对推理系统来说,一张图 = 一次额外的 encoder 计算 + 几百到上千个额外的 prefill token 。
为什么要关心
GPT-4o、Gemini、Claude、Qwen-VL、Llama 4 都原生支持图像输入;企业里文档解析、截图问答、视频理解的流量越来越大,serving 平台迟早要接 VLM。
一张高分辨率截图可能比整段对话的 token 还多。TTFT、KV Cache、计费、prefix caching 都要重新算账。
前面 34 课讲的 Transformer、KV Cache、调度全部照样适用;你只需要补上「图像怎么变成 token」这一环。
核心直觉
1. ViT:把图片当成一串 patch
224×224 图片 ──切成 16×16 的 patch──▶ 14×14 = 196 个 patch
每个 patch 拉平(16×16×3 = 768 个数)──Linear──▶ d 维向量 ← 相当于图像的「Embedding」
加上 2D 位置编码 ──▶ 标准 Transformer Encoder(双向 attention,无 causal mask)
输出:196 个向量,每个描述一小块图像及其上下文
ViT(Vision Transformer)的洞见是:不用卷积,把 patch 当 token 就行。它和 LLM 的区别只有两点:输入是像素块而不是 token id;attention 是双向的(看图不需要「不许偷看后面」)。
2. 怎么让 ViT 的向量「说 LLM 的语言」
单独训练的 ViT 学的是图像分类,向量空间和语言无关。CLIP (OpenAI 2021)用约 4 亿对「图片-描述」做对比学习 :让匹配的图文向量靠近、不匹配的远离。训完的 CLIP ViT 输出的向量天然和语言语义对齐,于是成了 VLM 最常用的视觉塔;后来的 SigLIP 把 softmax 对比损失换成 sigmoid,训练更省、效果更好,被很多开源 VLM 采用。
LLaVA 配方 (2023,今天大多数开源 VLM 的骨架):
图片 ─▶ [CLIP ViT-L/14 @336px] ─▶ 24×24 = 576 个向量 ─▶ [2 层 MLP Projector] ─▶ 576 个「图像 token」
│
文本:"<image>\n这张图里有什么?" ─▶ tokenizer ─▶ embedding ─────────────────┤ 在 <image> 位置拼接
▼
[LLM,如 Vicuna / Llama]
训练两阶段:
Stage 1 对齐:冻结 ViT 和 LLM,只训 Projector(图文描述数据)
Stage 2 指令微调:解冻 LLM,用「图 + 多轮问答」数据做 SFT(第 31 课)
Projector 就是一个小 MLP(第 04 课),把 ViT 的维度映射成 LLM 的 d_model。对 LLM 来说,图像 token 和文本 token 没有区别:一样过每一层 attention、一样写进 KV Cache。
3. 分辨率 = token 数 = 成本
固定 336px 看不清文档小字。现代 VLM 支持动态分辨率 ,代价是 token 数随像素面积线性增长:
Qwen2-VL / Qwen2.5-VL :patch 14×14,再把相邻 2×2 个 patch 合并成 1 个 token,所以每个图像 token ≈ 28×28 像素 ;位置编码用 M-RoPE(把 RoPE 拆成时间、高、宽三个分量,第 13 课)。
AnyRes / tiling (LLaVA-NeXT 等):把大图切成若干 336px 的 tile 分别编码,再加一张缩略图做全局视角。
视频 :按帧采样,每帧都是一张图,token 数 = 帧数 × 每帧 token 数,通常还要在时间维上再合并。
4. Early fusion vs Adapter
Adapter 式(late fusion) :拿现成的 ViT 和现成的 LLM,用 Projector 粘起来再微调。便宜、模块化,LLaVA、Qwen-VL 都是这一类。
原生多模态(early fusion) :从预训练开始就把图像 token 和文本 token 混在一起训(Meta 的 Chameleon、Llama 4 宣称的 native multimodality)。图文交互更深,但预训练成本和数据要求都高得多。
输出图像 / 语音 :要让模型「画图」或「说话」,需要把图像/音频离散成 token(VQ 码本、音频 codec)让 LLM 生成,或者接一个扩散模型做解码。这部分超出本课范围。
音频输入 同理:Whisper 的 encoder 把 30 秒音频编码成 1500 帧向量,再经 adapter 接入 LLM。
🧩 难点拆解:一张截图到底多贵
直觉 :把一张图想成一段「看不见的长 prompt」:它占 prefill 算力、占 KV Cache,只是这段 prompt 由 ViT 写成。
代入数字 :Qwen2.5-VL-7B(LLM 部分 28 层、4 个 KV head、head_dim 128,BF16),输入一张约 1008×1008 的截图:
token 数 :每 token 28×28 像素 → (1008/28)² = 36² = 1296 个图像 token 。合并之前 ViT 要处理 4 倍、即 5184 个 patch,所以 Qwen2.5-VL 在 ViT 里大量用 window attention 控制平方开销。
KV Cache :每 token = 2 × 28 × 4 × 128 × 2 B = 56 KiB(第 19 课公式),1296 个 token ≈ 71 MiB 。多轮对话里贴 10 张截图,就是 1.3 万 token、约 0.7 GiB,只是一个请求的量。
Prefill 算力 :≈ 2 × 7.6e9 参数 × 1296 token ≈ 2e13 FLOPs,H100 按 50% MFU 约 40 ms ,再加上 ViT 自己的 forward(Qwen2-VL 的 ViT 约 675M 参数,但要处理 5184 个 patch)。
所以同一个请求,「带一张高清图」的 TTFT 可能是纯文本的好几倍;很多 VLM 提供 max_pixels 一类参数,直接限制每张图的 token 上限。
一句话 :图像 token 数 ≈ 像素面积 ÷ 每 token 覆盖的像素面积;算出 token 数后,KV、prefill、计费全部沿用文本那一套账本。
代码片段
import torch, torch.nn as nn
class TinyVLM(nn.Module):
"""LLaVA 式 VLM 的骨架:ViT + Projector + LLM"""
def __init__(self, vit, llm, vit_dim, d_model, image_token_id):
super().__init__()
self.vit, self.llm = vit, llm
self.proj = nn.Sequential(nn.Linear(vit_dim, d_model), nn.GELU(),
nn.Linear(d_model, d_model)) # 2 层 MLP Projector
self.image_token_id = image_token_id
def forward(self, pixel_values, input_ids):
img = self.vit(pixel_values) # [n_img, n_patch, vit_dim],双向 attention
img = self.proj(img).flatten(0, 1) # [n_img * n_patch, d_model]
emb = self.llm.get_input_embeddings()(input_ids) # 文本 token → 向量(第 07 课)
mask = input_ids == self.image_token_id # prompt 里预留了 n_patch 个 <image> 占位 token
emb[mask] = img.to(emb.dtype) # 把图像向量填进占位位置
return self.llm(inputs_embeds=emb) # 之后与纯文本 LLM 完全一样
处理器(processor)会先把 prompt 里的一个 <image> 展开成和图像 token 数相同的占位 token,这样 emb[mask] 的形状才对得上。
关键一行是 inputs_embeds=emb:LLM 跳过自己的 embedding 查表,直接吃拼好的向量。之后的 attention、KV Cache、采样都不需要知道哪些位置来自图像。
Stage 1 训练时只有 self.proj 的参数在更新,ViT 和 LLM 都 requires_grad_(False)。
工程视角 / 真实世界
预处理是 CPU 活 :解码图片、resize、归一化在 CPU 上做,高并发时可能先于 GPU 成为瓶颈;vLLM 会缓存处理结果,并支持把预处理放到独立进程。
Encoder cache :chunked prefill(第 39 课)会把一个长 prompt 拆成多段;为了不重复跑 ViT,vLLM V1 有专门的 encoder cache 管理 ViT 输出,调度器同时要预算 encoder 算力和 KV 空间。
Prefix caching 要认图 :两次请求带了同一张图,图像 token 的 id 都是同一个占位符,光比 token id 会误判为相同前缀。引擎会对图像内容求 hash(vLLM 的 mm hash)并纳入 block hash(第 41 课)。
拆分趋势 :继 PD 分离(第 44 课)之后,社区在推进把 Encode 也拆出去(E/P/D 分离),让 ViT 跑在单独的实例上、按图像流量独立扩缩。
计费 :各家 API 基本都按「图像折算成多少 token」计费,原理就是上面的像素面积公式。
💬 能聊的要点
「主流 VLM 是 ViT + Projector + LLM:图片切 patch 过 ViT,MLP 映射到 LLM 的 embedding 空间,当作图像 token 拼进 prompt。LLaVA 两阶段训练:先只训 Projector 对齐,再解冻 LLM 做视觉指令微调。」
「图像 token 数和像素面积成正比,Qwen2-VL 每个 token 对应 28×28 像素,一张 1000 像素见方的截图约 1300 个 token,TTFT 和 KV Cache 都按这个量算。」
「VLM 推理比纯文本多三件事:CPU 预处理、ViT encoder 的计算和缓存、prefix caching 要对图像内容做 hash。趋势是把 encoder 也拆成独立服务。」
常见误区
❌ 多模态模型有一套完全不同的架构。✅ 大多数是「现成 LLM + 视觉塔 + 小 Projector」,LLM 主体和纯文本模型完全一样,推理引擎复用同一套 KV Cache 和调度。
❌ 一张图大约等于一个 token。✅ 一张图通常是几百到几千个 token,并且随分辨率线性增长。
❌ 图像越清晰越好。✅ token 数和成本随面积线性增长;很多任务把分辨率限制在够用的水平,效果几乎不变,延迟和成本大幅下降。
自测(心算/口答即可)
LLaVA-1.5 用 CLIP ViT-L/14、输入 336×336,一张图产生多少个图像 token?答案 336 / 14 = 24,24 × 24 = 576 个。
Qwen2-VL 输入一张 560×840 的图,大约多少图像 token?答案 每 token 28×28 像素:(560/28) × (840/28) = 20 × 30 = 600 个。
为什么 VLM 的 prefix caching 不能只比较 token id?答案 不同图片在 prompt 里都表示成同一个占位 token id,只比 id 会把内容不同的图当成相同前缀而错误复用 KV;必须把图像内容的 hash 纳入缓存 key。
延伸
🎓 Stanford CS336:Lecture 17(Alignment, multimodality)——见附录 CS336 对照与动手路线
🎬 李沐:「跟李沐学AI」ViT 论文逐段精读、CLIP 论文逐段精读
📄 Dosovitskiy et al. 2020, An Image is Worth 16x16 Words (ViT,arXiv:2010.11929)
📄 Radford et al. 2021, Learning Transferable Visual Models From Natural Language Supervision (CLIP,arXiv:2103.00020)
📄 Liu et al. 2023, Visual Instruction Tuning (LLaVA,arXiv:2304.08485)
📄 Wang et al. 2024, Qwen2-VL (动态分辨率与 M-RoPE,arXiv:2409.12191);Chameleon Team 2024, Chameleon: Mixed-Modal Early-Fusion Foundation Models
下一课 → 35 · GPU 速成:HBM、SRAM、SM、Tensor Core
35 · GPU 速成:HBM、SRAM、SM、Tensor Core,算力 vs 带宽
⏱ 10 分钟 | 前置:34 · LoRA 与 PEFT | 难度:★★☆
一句话
GPU 的计算能力(Tensor Core FLOPS)比它从显存搬数据的速度(HBM bandwidth)快两三百倍,所以一个 kernel(在 GPU 上跑的一个函数,见下文第 4 节)快不快,先看它的 Arithmetic Intensity(算术强度,每搬 1 byte 做多少 FLOP) 够不够高,推理优化里的技巧几乎都是围绕这一点展开的。
为什么要关心
后端工程师习惯把「CPU 忙不忙」当作性能指标,但在 GPU 上,多数 LLM 推理 kernel 的瓶颈是搬数据 ,不是计算。后面的 Roofline、KV Cache、PagedAttention、FlashAttention、量化,本质上都是在「少搬 byte」或「搬一次多算几次」。不先建立这套硬件直觉,你会听不懂为什么「decode 阶段 GPU 利用率只有百分之几」。
核心直觉
单位先对齐:FLOP = 一次浮点运算(一次乘法或一次加法);FLOPS = 每秒多少次 FLOP;1 TFLOPS = 10¹² FLOPS。一次「乘加」(a×b+c)算 2 FLOP。
1. 一张 GPU 长什么样
┌──────────────────── GPU (H100 SXM) ────────────────────┐
│ SM × 132 每个 SM 里有: │
│ ├─ 4 个 Tensor Core(做矩阵乘,BF16/FP8) │
│ ├─ CUDA cores(做逐元素运算:加、exp、比较) │
│ ├─ Register file 256 KB(64K 个 32-bit 寄存器) │
│ └─ L1 / Shared Memory (SRAM) 共 256 KB,其中最多 │
│ 228 KB 可配成 Shared Memory │
│ │
│ L2 Cache 50 MB(所有 SM 共享) │
└──────────────────────────┬─────────────────────────────┘
│ 3.35 TB/s
HBM3 80 GB(就是平时说的「显存」)
SM(Streaming Multiprocessor) :GPU 的「核」。H100 SXM 有 132 个,A100 有 108 个。一个 kernel 启动后,工作被切成很多 thread block (一组线程,负责一小块数据,比如输出矩阵的一个 128×128 tile),分发到各个 SM 上并行跑。可以把 SM 想成线程池里的 worker,thread block 是投进去的 task。
Tensor Core :专门做小块矩阵乘加(D = A×B + C)的硬件单元。LLM 里 >90% 的 FLOPs 是矩阵乘,所以「GPU 算力」基本就指 Tensor Core 算力。
HBM(High Bandwidth Memory) :容量大(80 GB)但相对「远」。模型权重、KV Cache 都住在这里。
SRAM(Shared Memory / L1 / Register) :就在 SM 旁边,极快但极小——每个 SM 只有两百多 KB,全卡的 L1/Shared Memory 加起来约 33 MB(132 × 256 KB),寄存器也是这个量级。
类比后端:HBM 像远程 Redis,SRAM 像进程内的 L1 cache。你不会每次循环都去 Redis 取一次数据,而是批量取回来、在本地多算几轮——GPU kernel 优化就是这个思路。
2. 关键数字(背下来就够用)
指标
A100 SXM 80GB
H100 SXM 80GB
BF16 Tensor Core(dense)
312 TFLOPS
约 989 TFLOPS
HBM 容量 / 带宽
80 GB / 约 2.0 TB/s(HBM2e)
80 GB / 3.35 TB/s(HBM3)
SM 数量
108
132
每 SM Shared Memory 上限
164 KB
228 KB
L2 Cache
40 MB
50 MB
注意:NVIDIA 宣传页常写 H100 BF16「1979 TFLOPS」,那是带 2:4 sparsity (结构化稀疏:每 4 个权重里强制 2 个为 0,硬件跳过它们)的数字;常规模型用不上,dense (稠密,正常矩阵乘)要除以 2。
3. Arithmetic Intensity 与「平衡点」
Arithmetic Intensity(算术强度) = 一个 kernel 的 总 FLOP / 从 HBM 搬的总 byte,衡量「每搬 1 byte 做多少活」。它是 kernel(算法)的属性,和硬件无关。
硬件也有一个对应的数:算力 / 带宽,叫平衡点(ridge point ):
A100:312e12 / 2.0e12 ≈ 156 FLOP/byte
H100:989e12 / 3.35e12 ≈ 295 FLOP/byte
大白话:在 H100 上,每从 HBM 读 1 byte,你得在上面做将近 300 次运算,Tensor Core 才不会闲着。kernel 强度低于 ridge point → memory-bound (瓶颈是搬数据);高于 → compute-bound (瓶颈是算)。
矩阵乘(GEMM) [M,K]×[K,N]:FLOPs = 2MKN,搬的数据约 2(MK+KN+MN) bytes(BF16)。M、N、K 都在几千时,强度可以上千 → compute-bound 。
矩阵×向量(GEMV) ,比如 batch=1 的 decode:每个权重(2 bytes)只做 1 次乘加(2 FLOP),强度 ≈ 1 FLOP/byte → 远低于 295,严重 memory-bound ,Tensor Core 大部分时间在等数据。
逐元素运算 (加 bias、GELU、softmax):强度更低,纯 memory-bound。这就是为什么要做 kernel fusion :把几个逐元素操作合成一个 kernel,中间结果留在 SRAM/寄存器,不写回 HBM。
🧩 难点拆解:算术强度与 ridge point
直觉 :把 GPU 想成一个厨房。HBM 是仓库,传送带每秒送来 3.35 TB 食材(带宽);Tensor Core 是厨师,每秒能切 989 T 刀(算力)。一道菜每份食材要切几刀,就是它的「算术强度」。刀数少的菜(切一刀就端走),厨师大部分时间在等传送带;刀数多的菜,传送带送来的东西厨师切不完。两者刚好打平的刀数,就是 ridge point。
小例子 (H100,同样读 1 GB 数据):
kernel
强度
读 1 GB 耗时
算完耗时
实际耗时
瓶颈
逐元素加法 c=a+b(读 2 个 BF16、写 1 个,6 byte 做 1 FLOP)
约 0.17
0.30 ms
0.0002 ms
0.30 ms
带宽
GEMV(batch=1 decode)
1
0.30 ms
0.001 ms
0.30 ms
带宽
GEMM,M=N=K=4096
2·4096³ / (2·3·4096²) ≈ 1365
0.30 ms
1.38 ms
1.38 ms
算力
算法:读数据时间 = 1 GB / 3.35 TB/s ≈ 0.30 ms;计算时间 = 强度 × 1 GB / 989 TFLOPS。两者谁大,谁就是实际耗时(读和算可以重叠)。强度 = 295 时两者恰好相等(都约 0.30 ms),这就是 ridge point 的含义。
一句话 :强度 < ridge point,提升性能靠少搬数据;强度 > ridge point,才轮到少算或换更快的 Tensor Core。
4. 什么是 kernel
Kernel = 在 GPU 上并行执行的一个函数。PyTorch 里每个 torch.matmul、F.softmax 底下都会发起一个或几个 kernel launch。每次 launch 的流程是:从 HBM 读输入 → 在 SM 上算 → 写回 HBM。所以「朴素写法」的代价是:每个算子都要把整个 tensor 在 HBM 里来回搬一遍 ,外加几微秒的 launch 开销(decode 阶段每步几百个 kernel,这部分开销不可忽略,这就是推理框架用 CUDA Graph 的原因:把一步 forward 的几百次 launch 录制成图,之后一次提交、整体重放;vLLM 默认是「分段」录制,attention 留在图外,见第 45 课)。
5. CUDA 与 Triton,一段话讲清
CUDA 是 NVIDIA 的 C++ 扩展:你以「一个 thread 做什么」的视角写代码,自己管理 thread block、shared memory、同步和 warp(32 个线程一组)。性能上限最高,但写起来门槛高,FlashAttention、vLLM 的 csrc/ 目录、NVIDIA 的 CUTLASS(C++ 矩阵乘模板库)都是这一层。Triton (OpenAI 开源)是 Python DSL:你以「一个 block 的数据做什么」的视角写代码,编译器帮你处理线程映射、shared memory 分配和很多访存优化,几十行就能写出接近手写 CUDA 的 fused kernel。torch.compile 生成的 GPU 代码就是 Triton,vLLM/SGLang 里也有大量 Triton kernel。
代码片段
用 PyTorch 粗测「算力 vs 带宽」的差距(读懂即可):
import torch, time
d = 8192
A = torch.randn(d, d, device="cuda", dtype=torch.bfloat16)
B = torch.randn(d, d, device="cuda", dtype=torch.bfloat16)
x = torch.randn(d, 1, device="cuda", dtype=torch.bfloat16) # 一个向量 = batch 1
def bench(f, n=50):
f(); torch.cuda.synchronize()
t = time.time()
for _ in range(n): f()
torch.cuda.synchronize()
return (time.time() - t) / n
t_gemm = bench(lambda: A @ B) # GEMM:2*d^3 FLOPs
t_gemv = bench(lambda: A @ x) # GEMV:2*d^2 FLOPs,但要读整个 A(2*d^2 bytes)
print("GEMM TFLOPS:", 2 * d**3 / t_gemm / 1e12) # 接近峰值的一大截
print("GEMV TFLOPS:", 2 * d**2 / t_gemv / 1e12) # 通常不到峰值的 1%
print("GEMV 实测带宽 GB/s:", 2 * d**2 / t_gemv / 1e9) # 接近 HBM 带宽
GEMM 的数字会接近 Tensor Core 峰值(通常能到峰值的 60–80%),它是 compute-bound。
GEMV 的 TFLOPS 很难看,但换算成「带宽」就接近 HBM 标称值——说明它被带宽卡住了,对 memory-bound kernel,要看 GB/s 而不是 TFLOPS 。
batch=1 的 LLM decode 本质就是一串 GEMV,下一课会用这个结论估算 tokens/s。
工程视角 / 真实世界
选卡 :推理服务常说「H100 比 A100 快 3 倍」,那是 compute-bound 场景(prefill、训练)。decode 为主的场景,提速更接近带宽比 3.35/2.0 ≈ 1.7 倍。H200(141 GB HBM3e,4.8 TB/s)算力和 H100 一样,但显存和带宽更大,所以它是「专门为推理加料」的卡。
MFU(Model FLOPs Utilization) :训练看 MFU(实际 FLOPs / 峰值),好的大规模训练在 40–50% 左右;推理 decode 看 MBU(Model Bandwidth Utilization) ,即实际带宽 / 峰值带宽。
源码去哪看 :vLLM 自己的 CUDA kernel 在 csrc/,当前 main 分支大部分已迁到 csrc/libtorch_stable/ 下(如 quantization/、moe/、layernorm_kernels.cu、custom_all_reduce.cu),attention 则主要调用外部库(FlashAttention、FlashInfer),见 vllm/v1/attention/backends/;SGLang 的 attention 后端在 python/sglang/srt/layers/attention/(triton_backend.py、flashinfer_backend.py、flashattention_backend.py 等),可以对照着看「同一个 attention,多个 kernel 实现」。
profiling :nsys(Nsight Systems)看时间线和 kernel launch 间隙,ncu(Nsight Compute)看单个 kernel 是 compute-bound 还是 memory-bound,这是 AI Infra 工程师的日常工具。
💬 能聊的要点
H100 BF16 dense 约 989 TFLOPS、HBM 3.35 TB/s,ridge point 约 295 FLOP/byte;任何强度低于这个数的 kernel 都是 memory-bound,优化方向是少搬数据而不是少算。
LLM 推理里矩阵乘在 prefill 时是 GEMM(compute-bound),decode 时退化成 GEMV(memory-bound),同一个模型两个阶段的瓶颈完全不同。
Kernel fusion 和 FlashAttention 的共同思想是:把中间结果留在 SRAM 里,别写回 HBM;SRAM 每 SM 只有两百多 KB,所以必须 tiling(分块)。
常见误区
❌ 「GPU 利用率 100%,说明算力用满了」 ✅ nvidia-smi 的 GPU-Util 只表示「采样时刻有 kernel 在跑」,一个 memory-bound kernel 也会显示 100%,但 Tensor Core 可能只用了 1%。
❌ 「H100 算力是 A100 三倍,推理就快三倍」 ✅ decode 受带宽限制,提速约等于带宽比(约 1.7 倍);只有 compute-bound 部分才接近三倍。
❌ 「SRAM 很快,那就把模型放 SRAM」 ✅ 全卡 SRAM 只有几十 MB,一个 8B 模型的 BF16 权重就 16 GB;SRAM 只能用来放 tile。
自测(心算/口答即可)
A100 上一个 kernel 的算术强度是 50 FLOP/byte,它是 compute-bound 还是 memory-bound?答案 memory-bound。A100 平衡点约 156 FLOP/byte,50 低于它,性能由带宽决定。
为什么 batch=1 的 GEMV 算术强度约为 1?答案 每个 BF16 权重占 2 bytes,读进来只和输入向量的一个元素做一次乘加(2 FLOP),2 FLOP / 2 bytes = 1。
Triton 和 CUDA 编程视角的最大区别是什么?答案 CUDA 以单个 thread 为视角,自己管 shared memory 和同步;Triton 以一个 block(一块数据)为视角,线程映射和 shared memory 由编译器处理。
延伸
🎬 Karpathy:Let's reproduce GPT-2 (124M) 第二部分「Let's make it fast」中 GPU、Tensor Cores、TF32(01:28:14)一节;仓库 llm.c 是看手写 CUDA kernel 的好素材
🎬 李沐:《动手学深度学习》第 12 章「计算性能」(12.4 硬件)
📄 NVIDIA Hopper Tuning Guide (docs.nvidia.com/cuda/hopper-tuning-guide);NVIDIA H100 Tensor Core GPU Architecture 白皮书;Horace He, Making Deep Learning Go Brrrr From First Principles (博客,compute / memory / overhead 三分法)
下一课 → 36 · 推理的两阶段:Prefill vs Decode,Roofline 与「内存墙」
36 · 推理的两阶段:Prefill vs Decode,Roofline 与「内存墙」
⏱ 10 分钟 | 前置:35 · GPU 速成 | 难度:★★☆
一句话
LLM 推理分两阶段:Prefill 一次性处理整个 prompt,是 compute-bound;Decode 每步只生成 1 个 token,却要把全部权重和 KV Cache 从 HBM 读一遍,是 memory-bound。所以 decode 的速度上限约等于 HBM 带宽 / 每步要读的字节数,而 batching 能让多个请求共用同一次权重读取。
为什么要关心
所有推理指标和优化都能归到这两个阶段:TTFT(Time To First Token,从发请求到看到第一个字的时间) 主要由 prefill 决定,TPOT(Time Per Output Token)/ ITL(Inter-Token Latency) (后续每个输出 token 之间的间隔,即「打字速度」)由 decode 决定。Continuous batching、PD 分离、投机解码、量化,各自针对的是其中一个阶段的瓶颈。能用 Roofline 算出「这张卡跑这个模型理论上最多多少 tokens/s」,在 Infra 岗位的讨论和面试里经常用到。
核心直觉
1. 两个阶段在做什么
Prompt: "The capital of France is" (6 tokens)
Prefill(1 次 forward,6 个 token 并行)
输入 [6, d] ──► 每层: [6,d] × W[d,d'] ← GEMM,矩阵 × 矩阵
产出: 全部 6 个位置的 KV Cache + 第 1 个输出 token "Paris"
Decode(每步 1 次 forward,只有 1 个新 token)
输入 [1, d] ──► 每层: [1,d] × W[d,d'] ← GEMV,向量 × 矩阵
+ 读取之前所有 token 的 KV Cache 做 attention
产出: 下 1 个 token,追加 1 个位置的 KV Cache
…… 循环直到 EOS(模型输出「结束」token)或达到 max_tokens 上限
(KV Cache = 把每个已处理 token 在每层算出的 Key/Value 向量存起来,decode 时新 token 只需和它们做 attention,不必重算历史,第 37 课详讲。)
关键区别在于:两个阶段都要把整个模型的权重从 HBM 读一遍 ,但 prefill 读一次能处理几千个 token,decode 读一次只处理 1 个 token(batch=1 时)。
2. Roofline 模型
可达 FLOPS
▲
│ ┌─────────────────── 峰值算力(H100 BF16 ≈ 989 TFLOPS)
│ /
│ / ← 斜率 = HBM 带宽(3.35 TB/s)
│ /
│ decode ● / ● prefill(强度 ≈ prompt 长度,几千)
│ (≈B) /
└─────────┴─────────────────────────► Arithmetic Intensity (FLOP/byte)
≈295(ridge point)
公式:可达性能 = min(峰值算力, 算术强度 × 带宽)。大白话:强度低于 ridge point 时,性能随强度线性上涨(带宽墙);高于它时,性能被算力封顶。
代数字:H100 上强度 = 10 的 kernel,10 × 3.35 TB/s = 33.5 TFLOPS,只有峰值的 3%;强度 = 1000 时 1000 × 3.35 = 3350 TFLOPS 超过峰值,取 989,被算力封顶。
对 Transformer 的线性层(QKV 投影、FFN 等矩阵乘,占绝大部分 FLOPs):每个 BF16 权重读 2 bytes,对本步的每个 token 做一次乘加(2 FLOP),所以
线性层算术强度 ≈ 本次 forward 处理的 token 数 (FLOP/byte)
Prefill 2000 个 token → 强度约 2000,远超 295 → compute-bound
Decode batch=1 → 强度约 1 → memory-bound,Tensor Core 利用率不到 1%
Decode batch=B → 强度约 B,要 B 接近 300 才能在 H100 上摸到算力墙
3. 算一个例子:Llama-3-8B,H100,batch=1
权重:约 8B 参数 × 2 bytes(BF16)≈ 16 GB
每个 decode step 至少读 16 GB(KV Cache 短时可忽略)
单步耗时下限 ≈ 16 GB / 3.35 TB/s ≈ 4.8 ms
单请求 decode 上限 ≈ 210 tokens/s ;A100(约 2.0 TB/s)上约 125 tokens/s
实际框架由于 kernel launch、attention、采样等开销,带宽利用率(MBU)达不到 100%,所以实测会低于这个上限。这个估算也解释了为什么权重量化能直接提升单请求 decode 速度 :FP8/INT8 让要读的字节减半,上限接近翻倍;INT4 理论上接近 4 倍(实测因反量化等开销会打折扣)。这和算力无关。
再看 prefill 一个 2000 token 的 prompt:FLOPs ≈ 2 × 参数量 × token 数(每个参数对每个 token 做一次乘加 = 2 FLOP;忽略 attention 本身)= 2 × 8e9 × 2000 = 32 TFLOP。按 H100 峰值要 32 ms,按更现实的 50% 利用率约 65 ms;而读一遍权重只要 4.8 ms,所以时间由算力决定。
4. 为什么 batching 能救 decode
先忽略 KV Cache,把 64 个请求拼成一个 batch 一起 decode:权重还是只读一次(16 GB),但一步产出 64 个 token。单步时间几乎不变(仍约 5 ms),总吞吐从约 200 tokens/s 变成约 13,000 tokens/s ,而每个用户的 TPOT 几乎不受影响。这就是推理服务一定要做 batching 的原因。
但是 batching 并不能无限提升吞吐,有两个限制:
KV Cache 不共享 :每个请求的 attention 要读自己的 KV Cache,这部分的强度不会随 batch 增大而提高。Llama-3-8B 每 token KV 128 KB,64 个请求 × 2048 上下文 = 16 GB,每步要读的 KV 已经和权重一样多了 ,单步时间翻倍。
显存容量 :batch 越大,KV Cache 占的显存越多,放不下就只能排队。
所以 decode 的吞吐上限很大程度上取决于显存里能放多少 KV Cache ,下一课详细计算。
🧩 难点拆解:为什么 decode 是 memory-bound,batching 为什么有效
直觉 :decode 一步就像一辆公交车跑一趟全程(把 16 GB 权重从 HBM 拉一遍)。车跑一趟的时间几乎固定,和车上坐 1 个人还是 64 个人关系不大。batch=1 等于每趟只拉 1 个乘客;batching 就是让一趟车多坐人。但每个乘客还带着自己的行李(KV Cache),行李多了车也会变慢。
小例子 (Llama-3-8B,H100,上下文 2048,一次 decode step):
batch = 1
batch = 64(先忽略 KV)
batch = 64(算上 KV)
读的 byte
权重 16 GB(KV 仅 0.25 GiB)
权重 16 GB
权重 16 GB + KV 16 GiB ≈ 33 GB
做的 FLOP
2 × 8e9 × 1 = 16 GFLOP
2 × 8e9 × 64 ≈ 1.0 TFLOP
同左 ≈ 1.0 TFLOP
强度
≈ 1
≈ 64
≈ 31
读数据耗时
4.8 ms
4.8 ms
9.9 ms
计算耗时
0.016 ms
1.0 ms
1.0 ms
单步耗时(取大者)
4.8 ms
4.8 ms
9.9 ms
吞吐
≈ 206 tok/s
≈ 13,000 tok/s
≈ 6,500 tok/s
一步步看:
batch=1:读 16 GB 要 4.8 ms,算 16 GFLOP 只要 0.016 ms——Tensor Core 99% 以上的时间在等数据,这就是「memory-bound」。
batch=64(忽略 KV):读的字节数不变,FLOP 变成 64 倍,但计算仍然只要 1 ms,远小于 4.8 ms;单步耗时不变,吞吐 ×64。
算上 KV:每个请求各读自己的 KV,64 × 2048 × 128 KB = 16 GiB,读取量翻倍,单步耗时也翻倍。吞吐还是比 batch=1 高约 31 倍,但不再是 64 倍。
要让计算耗时追上读取耗时,强度需要接近 295,也就是 batch 约 300(且 KV 很小)——这就是「decode 很难吃满算力」的原因。
一句话 :decode 的成本主要是「读一遍权重」,batching 让多个请求分摊这次读取;分摊不了的是每个请求自己的 KV。
代码片段
一个粗糙但实用的 decode 性能估算器:
def decode_step_time(params_b, bytes_per_param, batch, ctx_len,
kv_bytes_per_token, bw_tbs, peak_tflops):
"""估算一次 decode step 的时间(秒),忽略 kernel 开销"""
weight_bytes = params_b * 1e9 * bytes_per_param # 权重:所有请求共享,读一次
kv_bytes = batch * ctx_len * kv_bytes_per_token # KV:每个请求各读各的
t_mem = (weight_bytes + kv_bytes) / (bw_tbs * 1e12) # 带宽墙
flops = 2 * params_b * 1e9 * batch # 线性层 FLOPs(忽略 attention)
t_compute = flops / (peak_tflops * 1e12) # 算力墙
return max(t_mem, t_compute) # Roofline:取较慢者
# Llama-3-8B on H100:KV 每 token 128 KB
for b in [1, 16, 64, 256]:
t = decode_step_time(8, 2, b, 2048, 128 * 1024, 3.35, 989)
print(f"batch={b:4d} step={t*1e3:5.1f} ms 吞吐={b/t:8.0f} tok/s")
weight_bytes 与 batch 无关,kv_bytes 与 batch × ctx_len 成正比,这就是「长上下文 + 大 batch 时 KV 成为主要瓶颈」的来源。
运行结果:batch=1 约 206 tok/s,batch=64 约 6,500 tok/s(单步 9.9 ms)。吞吐提升约 31 倍而不是 64 倍,因为 2048 上下文下 KV 读取(16 GiB)已经和权重一样多。
到 batch=256 时 KV 读取达 64 GiB(单卡也放不下),单步 25 ms,边际收益明显下降。
max(t_mem, t_compute) 就是 Roofline 本身。
工程视角 / 真实世界
指标对应 :TTFT ≈ 排队时间 + prefill 时间;TPOT ≈ decode step 时间。一个大 prefill 插进 batch 会拖慢所有正在 decode 的请求,这就是第 39 课 chunked prefill 要解决的问题。
PD 分离(Prefill/Decode Disaggregation) :既然两个阶段瓶颈不同,干脆把它们放到不同的 GPU 池(DistServe、Mooncake、vLLM/SGLang 的 disaggregated serving),第 44 课详细讲。
投机解码 的出发点也在这里:decode 时算力是闲置的,那就让大模型一次验证 k 个草稿 token,把 GEMV 变回小 GEMM,第 42 课。
MoE 模型 :decode 只读被激活 expert 的权重,但 batch 大时不同 token 会激活不同 expert,实际读取量会趋近全部 expert,这是 MoE 推理需要 EP(Expert Parallel,把不同 expert 放到不同卡上,第 44 课)的原因之一。
💬 能聊的要点
线性层的算术强度约等于本次 forward 的 token 数:prefill 几千,decode batch=1 时只有 1,所以 prefill 是 compute-bound,decode 是 memory-bound。
单请求 decode 速度上限约为 带宽 / 模型字节数:8B BF16 模型在 H100 上约 210 tokens/s;量化减少字节数,能直接提高这个上限。
Batching 通过共享权重读取提升 decode 吞吐,但 KV Cache 每个请求独占,大 batch 长上下文时 KV 读取和显存容量会成为新的瓶颈,这是 PagedAttention、GQA/MLA、KV 量化都要解决的问题。
常见误区
❌ 「decode 慢是因为 GPU 算力不够」 ✅ decode 时 Tensor Core 大多空闲,瓶颈是 HBM 带宽;换算力更强但带宽相同的卡几乎没用。
❌ 「batch 越大,每个用户越慢」 ✅ 在 memory-bound 区间,batch 增大时单步时间基本不变,吞吐线性增长,TPOT 几乎不变;只有进入 compute-bound 或 KV 读取主导后,TPOT 才会明显变差。
❌ 「prefill 只算一次,不重要」 ✅ 长 prompt(RAG、agent、几万 token 的上下文)下 prefill 决定 TTFT,并且会阻塞同 batch 里的 decode。
自测(心算/口答即可)
一个 70B 模型 BF16 部署在 8 张 H100 上(TP=8,Tensor Parallel:每层权重切成 8 份,每卡读 1/8),batch=1 的 decode 上限约多少 tokens/s?答案 权重约 140 GB,8 卡总带宽约 26.8 TB/s,单步约 5.2 ms,上限约 190 tokens/s(实际还要扣掉 TP 的 all-reduce 通信开销)。
为什么 prefill 的算术强度约等于 prompt 长度?答案 每个权重读进来一次,要和 prompt 中每个 token 各做一次乘加,所以 FLOP/byte 约等于 token 数。
Llama-3-8B 在 H100 上 decode,batch 从 1 提到 32,吞吐大约变成多少倍?答案 短上下文时接近 32 倍(权重读取共享,单步时间主要是读 16 GB 权重);上下文越长,KV 读取越多,倍数越低(如 2048 上下文时 32×2048×128 KB = 8 GiB,单步约 7.4 ms,约 21 倍)。
延伸
🎬 Karpathy:Intro to Large Language Models (1 小时)开头「一个模型就是两个文件」部分,可以帮助建立「推理 = 反复读参数文件」的直觉
🎬 李沐:《动手学深度学习》第 12 章「计算性能」
📄 Williams et al., Roofline: An Insightful Visual Performance Model for Multicore Architectures (CACM 2009);Databricks 博客 LLM Inference Performance Engineering: Best Practices (MBU 概念);Yuan et al., LLM Inference Unveiled: Survey and Roofline Model Insights (2024)
下一课 → 37 · KV Cache:原理与显存账本
37 · KV Cache:原理与显存账本
⏱ 10 分钟 | 前置:36 · Prefill vs Decode 与 Roofline | 难度:★★☆
一句话
KV Cache 把每个已处理 token 在每一层的 Key/Value 向量存下来,让 decode 每步只算新 token,避免从头重算;代价是显存:每 token 占 2 × n_layers × n_kv_heads × head_dim × bytes,并且随 batch × 上下文长度 线性增长,这是推理服务里最主要的显存问题。
为什么要关心
推理服务的显存只有两大块:权重 (固定)和 KV Cache (随负载变化)。能同时服务多少请求、支持多长上下文、吞吐能做到多少,基本都由 KV Cache 放得下多少决定。后面的 PagedAttention、Prefix Caching、GQA/MLA、KV 量化、PD 分离,都是在管理、压缩或搬运 KV Cache。
核心直觉
1. 为什么能缓存、为什么要缓存
Causal attention 里,第 t 个 token 的输出只依赖位置 ≤ t 的 K、V。而且过去 token 的 K、V 不会因为后面来了新 token 而改变 (因果掩码保证了这一点)。所以:
不缓存:生成第 t 个 token 时,把前 t-1 个 token 全部重新过一遍模型 → 总计算 O(n²) 次 token-forward
有缓存:
step t: q_t, k_t, v_t = 新 token 的投影
K_cache.append(k_t); V_cache.append(v_t) # 每层各存一份
out = softmax(q_t · K_cacheᵀ / √d) · V_cache # 只有 1 个 query
Q 不需要缓存:过去 token 的 query 以后再也用不到。这就是它叫 KV Cache 而不是 QKV Cache 的原因。
用后端的话说,KV Cache 是一个按请求隔离、只追加(append-only)、生命周期等于请求长度 的缓存,而且事先不知道它最终会有多长。
2. 显存公式
KV bytes / token = 2 × n_layers × n_kv_heads × head_dim × bytes_per_elem
↑K和V
KV 总量 = KV bytes/token × 上下文长度 × 并发请求数
大白话:每层、每个 KV head 都要为每个 token 存一个 K 向量和一个 V 向量。
符号说明:n_layers = Transformer 层数(8B 模型通常 32);n_kv_heads = K/V 的 head 数(MHA 时等于 query head 数,GQA 时更少,第 19 课);head_dim = 每个 head 的向量长度(常见 128);bytes_per_elem = 每个数的字节数(BF16 = 2,FP8 = 1)。
🧩 难点拆解:KV Cache 公式怎么来的
直觉 :把 KV Cache 想成一张按 token 追加行的表。每来一个 token,就往表里写一行;一行里有「每层 × 每个 KV head × (K, V)」这么多个小向量。公式就是在数这一行有多大。
小例子 (Llama-3-8B,BF16),从最小单位往外乘:
1 个 head 的 K 向量 = head_dim 128 个数 × 2 byte = 256 B
× 2(K 和 V) = 512 B
× 8 个 KV head(GQA) = 4 KB ← 1 层
× 32 层 = 128 KB ← 1 个 token
× 8192 token(一个 8K 上下文请求) = 1 GB ← 1 个请求
× 32 个并发请求 = 32 GB ← 整个 batch
对照:如果 Llama-3-8B 用 MHA(32 个 KV head),第三行就是 16 KB,每 token 512 KB,是现在的 4 倍。
一句话 :每 token KV = 「每层每头一对 K/V 向量」× 层数 × KV 头数;再乘上下文长度和并发数,就是 KV Cache 总账。
3. 算几个真实模型
模型
层数
KV heads × head_dim
注意力类型
每 token KV(BF16)
OPT-13B(vLLM 论文用例)
40
40 × 128(=5120)
MHA
800 KB
Llama-2-7B
32
32 × 128
MHA
512 KB
Llama-3-8B
32
8 × 128
GQA
128 KB
Llama-3-70B
80
8 × 128
GQA
320 KB
DeepSeek-V3
61
压缩 latent 512 + RoPE 64
MLA
约 69 KB
单位说明:本 Part 的 KB/MB/GB 按 1024 进制计(即 KiB/MiB/GiB,第 19 课写法),128 KB = 131,072 字节。
例子:
Llama-3-8B,32 个并发,每个 8K 上下文 :128 KB × 8192 × 32 = 32 GB ,是权重(16 GB)的两倍。
Llama-3-70B,单个 128K 上下文请求 :320 KB × 131072 = 40 GB ,一个请求就占掉半张 H100。
OPT-13B,最长 2048 token :一个请求最多 1.6 GB(vLLM 论文原文的例子)。
对比 Llama-2-7B 和 Llama-3-8B:模型大小差不多,每 token KV 差 4 倍,差别就在 GQA (32 个 query head 共享 8 个 KV head)。这就是第 19、20 课 GQA/MLA 对推理如此重要的原因——它们直接决定了 KV Cache 的大小。(MLA 不存每个 head 的 K/V,而是每层只存一个 512 维的压缩 latent + 64 维 RoPE key:(512+64) × 61 层 × 2 byte ≈ 69 KB,所以公式里没有「× 2」和「× n_kv_heads」。)
4. 为什么它是最主要的显存问题
一张 H100 80 GB 跑 Llama-3-8B:
┌──────────────┬───────────┬──────────────────────────────────────────┐
│ 权重 16 GB │ 激活/杂项 │ KV Cache 可用 ≈ 55 GB │
└──────────────┴───────────┴──────────────────────────────────────────┘
55 GB / 128 KB ≈ 45 万 token → 例如 8K 上下文约 55 个并发,32K 上下文约 13 个并发
四个特点叠在一起让它很难管理:
大 :如上,很容易超过权重本身。vLLM 论文中,13B 模型在 A100 40GB 上,权重占约 65%,KV Cache 占 30% 以上。
动态增长 :每 decode 一步每个请求就多一个 token 的 KV。
长度未知 :请求什么时候输出 EOS,事先不知道。
直接决定吞吐 :上一课说过,decode 吞吐靠 batch 提升,而 batch 大小受 KV Cache 容量限制。
所以早期系统为每个请求按 max_len 预留一段连续显存 ,结果大量浪费(下一课有论文数字),这就是 PagedAttention 的出发点。
代码片段
先是整课的公式(面试时能直接写出来):
def kv_bytes_per_token(n_layers, n_kv_heads, head_dim, dtype_bytes=2):
return 2 * n_layers * n_kv_heads * head_dim * dtype_bytes # 2 = K 和 V
print(kv_bytes_per_token(32, 8, 128) / 1024) # Llama-3-8B → 128.0 KB
print(kv_bytes_per_token(80, 8, 128) / 1024) # Llama-3-70B → 320.0 KB
再看 KV Cache 在生成循环里到底怎么「写」和「读」(单头、省略 MLP,只保留骨架):
class AttnLayer:
def __init__(self):
self.K, self.V = [], [] # 这一层自己的 KV Cache(每层各一份 → 公式里的 n_layers)
def forward(self, x): # x: [B, t_new, d],prefill 时 t_new=整段 prompt,decode 时 =1
q, k, v = x @ Wq, x @ Wk, x @ Wv # 只为「新来的 token」算投影
self.K.append(k); self.V.append(v) # ① 写:新 token 的 K/V 追加进 cache
K = torch.cat(self.K, dim=1) # ② 读:历史 + 新的全部 K/V,[B, t_total, d]
V = torch.cat(self.V, dim=1)
att = q @ K.transpose(1, 2) / d ** 0.5 # [B, t_new, t_total]
if x.shape[1] > 1: # prefill:新 token 之间要 causal mask
att = att.masked_fill(future_mask(att), float('-inf'))
return att.softmax(-1) @ V # decode(t_new=1):新 token 本来就在最后,无需 mask
# 生成循环:Prefill 一次,Decode 很多次
logits = model(embed(prompt_ids)) # Prefill:1000 个 token 一次过 → 填满 1000 格 cache
next_id = logits[:, -1].argmax(-1) # 大矩阵乘,compute-bound,决定 TTFT
for _ in range(max_new_tokens): # Decode:每步只喂 1 个 token
logits = model(embed(next_id)[:, None]) # 算 1 个 token,却要把整个 cache 读一遍
next_id = logits[:, -1].argmax(-1) # memory-bound,决定 TPOT
没有 cache 会怎样 :第 n 步要把前 n 个 token 的 K/V 全部重算一次,生成 N 个 token 总共重算约 N²/2 次投影;有了 cache,每个 token 的 K/V 只算一次。cache 省的是计算 ,代价是显存容量和带宽 。
① 写 ② 读 就是 Infra 关心的全部:写决定显存容量 (每 token 一份,一直留到请求结束),读决定 decode 的带宽 消耗(每步都读全部历史)。
torch.cat 每步都会复制整个 cache,只是为了好读;真实框架预先分配固定大小的 block,按 block table 定位(下一课 PagedAttention)。
工程视角 / 真实世界
vLLM 启动时的显存规划 :先按 gpu_memory_utilization(当前 main 默认 0.92,早期版本 0.9)算出总预算,做一次 profiling 测出权重和激活的峰值,剩下的全部切成 KV block。日志里会打印 GPU KV cache size: N tokens 和 Maximum concurrency for X tokens per request: Y x,排查「为什么并发上不去」时先看这两行。相关代码在 vllm/v1/core/kv_cache_utils.py。
max_model_len 的影响 :设成 128K 但 KV 只能放 45 万 token,意味着只有 3 个请求能同时拿到满长度,调度器会频繁抢占。
减小 KV 的几种方法 :GQA/MQA(减少 head 数)、MLA(低秩压缩,DeepSeek)、KV Cache 量化到 FP8(--kv-cache-dtype fp8,直接减半)、滑动窗口注意力(只保留最近 W 个 token)、把冷的 KV 卸载到 CPU/SSD(LMCache、Mooncake 的 KV store)。
多卡 :TP=8(Tensor Parallel,每层按 head 切到 8 张卡,第 44 课)时,KV heads 按卡切分,每张卡只存 n_kv_heads/8 个头;但如果 n_kv_heads < TP,KV 要在卡间复制,这是 GQA 模型做大 TP 时需要注意的地方。
💬 能聊的要点
KV Cache 每 token 大小 = 2 × 层数 × KV head 数 × head_dim × 字节数;Llama-3-8B 是 128 KB,Llama-2-7B 因为是 MHA 要 512 KB,差 4 倍全在 GQA。
KV Cache 用显存换计算:不缓存时 decode 总计算是 O(n²) 次 token-forward,缓存后每步只算新 token,但每步要读全部历史 KV,所以长上下文 decode 越来越受带宽限制。
推理服务的并发上限基本等于「KV 可用显存 / (每 token KV × 平均上下文长度)」,所以 PagedAttention、prefix caching、KV 量化这些手段都能直接转化为吞吐提升。
常见误区
❌ 「KV Cache 是跨请求共享的缓存」 ✅ 默认是每个请求私有的;只有 prompt 前缀完全相同时,prefix caching 才能跨请求复用(第 41 课)。
❌ 「Q 也要缓存」 ✅ 过去 token 的 query 以后不会再用,只有 K、V 会被后续 token 读取。
❌ 「显存够放下模型就能跑高并发」 ✅ 权重只是固定成本;长上下文、高并发时 KV Cache 往往是权重的数倍。
自测(心算/口答即可)
一个 32 层、32 个 KV head、head_dim 128 的 MHA 模型,4K 上下文单请求 KV 多大?改成 8 个 KV head 呢?答案 每 token 2×32×32×128×2 = 512 KB,4K 上下文 2 GB;改成 8 个 KV head(GQA)后变成 512 MB。
Llama-3-8B 的 KV Cache 从 BF16 改成 FP8,同样显存能多放多少 token?答案 每 token 从 128 KB 降到 64 KB,可放 token 数翻倍。
为什么说 KV Cache「省计算、费带宽」?答案 它避免重算历史 token 的 K/V(省计算),但每个 decode step 都要从 HBM 读出全部历史 KV 做 attention,上下文越长读得越多(费带宽和显存)。
延伸
🎬 Karpathy:Let's build GPT: from scratch, in code, spelled out. 里的 generate() 没有 KV Cache,每步都把整个上下文重新 forward,对照着看能理解 KV Cache 省掉了什么
🎬 李沐:「Transformer 论文逐段精读」中 decoder 自回归与 mask 的讲解
📄 Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (SOSP 2023) §3;Ainslie et al., GQA (2023);vLLM 源码 vllm/v1/core/kv_cache_utils.py
下一课 → 38 · PagedAttention:把操作系统的分页搬进显存
38 · PagedAttention:把操作系统的分页搬进显存(vLLM 核心)
⏱ 10 分钟 | 前置:37 · KV Cache:原理与显存账本 | 难度:★★☆
一句话
PagedAttention 把每个请求的 KV Cache 切成固定大小的 block (默认 16 个 token),通过 block table 把「逻辑上连续」映射到「物理上分散」的显存块,按需分配;这样显存浪费从原来的 60–80% 降到 4% 以内,同样的显存能放 2–4 倍的请求,并且支持像 OS 一样的 copy-on-write 共享。
为什么要关心
vLLM 就是靠这篇论文(Kwon et al., SOSP 2023)起家的,PagedAttention 也是今天几乎所有推理引擎(vLLM、SGLang、TensorRT-LLM、TGI)KV 管理的基础。面试官问「vLLM 为什么快」,第一个答案就是它;后面的 continuous batching 抢占、prefix caching、PD 分离的 KV 传输,也都以 block 为单位。
核心直觉
1. 问题:连续预分配浪费了多少
PagedAttention 之前的系统(论文对比的 FasterTransformer——NVIDIA 的推理库,和 Orca——第 39 课讲的第一个 continuous batching 系统)沿用深度学习框架「tensor 必须连续」的习惯:请求一到,就按最大长度(如 2048 token)预留一段连续显存 。
请求 A 预留 2048 slot,实际用了 600:
[■■■■■■ 已用 ■■■■■■|□□□□ 预留给以后生成的 □□□□|×××× 永远用不到 ××××]
reservation(暂时浪费) internal fragmentation
请求 A、B、C 之间: [A......][ 空洞 ][B.........][ 空洞 ][C...]
external fragmentation(大小不一,拼不出新请求)
论文的实测(Fig. 2):现有系统里只有 20.4%–38.2% 的 KV Cache 显存真正存了 token 状态;vLLM 是 96.3% 。
2. 解法:虚拟内存那一套
OS 虚拟内存
PagedAttention
进程
请求(sequence)
page(如 4 KB)
KV block(每 block 存 B 个 token 的 K、V,vLLM 默认 B=16)
虚拟页号 → 物理页帧
logical block → physical block
page table
block table
缺页时按需分配
上一个 block 写满了才分配下一个
fork 后共享页 + copy-on-write
parallel sampling / beam search 共享 prompt 的 block,写入时复制
swap 到磁盘
抢占时 swap 到 CPU 内存(或重算)
请求 A 的 logical blocks block table 物理显存(block pool)
┌────────────────────┐ ┌───┬────┐ ┌────┬────┬────┬────┬────┐
│ L0: "The cat sat" │ ──────► │ 0 │ 7 │ ─────────► │ #0 │ #1 │ #2 │ #3 │ .. │
│ L1: "on the mat" │ │ 1 │ 1 │ ├────┼────┼────┼────┼────┤
│ L2: "and" (未满) │ │ 2 │ 3 │ │ #4 │ #5 │ #6 │ #7 │ .. │
└────────────────────┘ └───┴────┘ └────┴────┴────┴────┴────┘
A 的数据散落在 #7、#1、#3
效果:
没有 external fragmentation :所有 block 大小相同,任何空闲 block 都能分给任何请求。
internal fragmentation 最多一个 block :只有每个请求的最后一个 block 可能没写满(最多浪费 B−1 个 slot)。
没有 reservation :不为「将来可能生成的 token」预留。
🧩 难点拆解:跟着一个请求走一遍 block table
直觉 :block table 就是每个请求一张「第几页 → 放在哪个物理页框」的小数组。写入时查它决定写到哪,读取(attention)时查它决定去哪读。
小例子 (为了画得下,block size 取 4;物理 block 池共 8 个,#0–#7):
① Prefill:请求 A 的 prompt 有 7 个 token → 需要 ceil(7/4) = 2 个 block
从空闲池拿到 #7、#1 block table A = [7, 1]
token 0–3 → #7 的 4 个 slot;token 4–6 → #1 的前 3 个 slot(#1 还空 1 格)
同时请求 B 进来,拿到 #0、#2 block table B = [0, 2]
② Decode 第 1 步:生成 token 7
7 // 4 = 1 → 逻辑 block 1 → 查表得物理 #1,offset = 7 % 4 = 3 → 写进 #1 最后一格
不需要新 block
③ Decode 第 2 步:生成 token 8
8 // 4 = 2 → 逻辑 block 2 不存在 → 从空闲池拿 #3 block table A = [7, 1, 3]
写进 #3 的 offset 0
④ 请求 A 结束:#7、#1、#3 的引用计数减到 0,还给空闲池,马上可以分给别人
写入位置在代码里叫 slot_mapping :slot = 物理 block 号 × block_size + offset,比如 token 7 → 1×4+3 = 7,token 8 → 3×4+0 = 12。Attention kernel 读 A 的历史 KV 时,按 [7, 1, 3] 依次去这三个物理 block 取数。
类比在哪里不成立 :OS 缺页时由 MMU 硬件查页表;这里没有硬件帮忙,是 attention kernel 自己用软件查表,所以 kernel 要专门改写(下一节)。
一句话 :逻辑位置 i → table[i // B] 找物理 block,i % B 找格子;只有最后一个 block 可能没写满,满了才去池子里再拿一个。
3. Attention kernel 要怎么改
K、V 不再连续,标准 attention kernel 无法直接使用。PagedAttention kernel 在计算时按 block table 去各个物理 block 取 K、V ,逐 block 计算 attention。这是它名字中 “Attention” 的来源——它首先是一个能读取非连续 KV 的 attention kernel。
代价:论文测得 kernel 延迟比高度优化的 FasterTransformer 实现高 20–26% (查 block table、额外分支、变长序列),但只影响 attention 这一个算子,端到端仍然大幅领先。
4. 共享与 Copy-on-Write
Parallel sampling(一个 prompt 采 n 个回答)和 beam search(每步保留得分最高的 k 条候选序列,候选之间共享大段前缀)时,多个序列的 prompt KV 完全一样:
seq1 ─┐ 每个物理 block 维护 ref_count
seq2 ─┼─► [P0][P1][P2*] ref=3
seq3 ─┘ ↑ 最后一个 block 没写满
seq1 要往 P2 写新 token 时:ref_count>1 → 复制一份 P2' 给 seq1,P2 的 ref 减 1(copy-on-write)
论文数据:共享使 parallel sampling 省 6.1%–9.8% 显存,beam search(候选之间共享更多)省 37.6%–55.2% 。
5. block size 的取舍
block 太小 → block table 更长、kernel 读取更碎,GPU 并行度差;block 太大 → 内部碎片变多,也更难共享。论文在 ShareGPT 上测得 16–128 都不错,Alpaca(短序列)上 16、32 最好,于是默认选了 16 。
代码片段
极简 block manager(思路与 vLLM 的 BlockPool 一致):
class BlockManager:
def __init__(self, num_blocks, block_size=16):
self.block_size = block_size
self.free = list(range(num_blocks)) # 空闲物理 block 池
self.ref = [0] * num_blocks # 每个物理 block 的引用计数
self.tables = {} # req_id -> [physical block ids]
def append_token(self, req_id, num_tokens_after):
table = self.tables.setdefault(req_id, [])
if num_tokens_after > len(table) * self.block_size: # 最后一个 block 满了
if not self.free:
raise MemoryError("no free block -> scheduler 需要抢占")
b = self.free.pop()
self.ref[b] = 1
table.append(b) # 按需分配,一次只多一个 block
def fork(self, parent, child): # parallel sampling:共享 prompt
self.tables[child] = list(self.tables[parent])
for b in self.tables[child]:
self.ref[b] += 1 # 只加引用,不复制数据
def free_req(self, req_id):
for b in self.tables.pop(req_id):
self.ref[b] -= 1
if self.ref[b] == 0:
self.free.append(b) # 引用归零才真正回收
append_token 体现「按需分配」:写满一个 block 才申请下一个,浪费不超过一个 block。
raise MemoryError 那一行在真实系统里不会报错,而是触发调度器的抢占 (下一课)。
fork + ref 就是 copy-on-write 的基础;写入前检查 ref > 1 再复制即可(此处省略)。
工程视角 / 真实世界
vLLM V1 源码位置 :
vllm/v1/core/block_pool.py:BlockPool,管理空闲 block 队列、ref_cnt、以及 prefix cache 的哈希表
vllm/v1/core/kv_cache_manager.py:KVCacheManager,给调度器提供 allocate_slots / free 等接口
vllm/v1/worker/block_table.py:GPU 侧的 BlockTable,形状 [max_num_reqs, max_num_blocks_per_req] 的 int32 tensor,外加 slot_mapping(每个新 token 写到哪个物理 slot)
kernel 已经外包 :最早的 PagedAttention CUDA kernel(paged_attention_v1/v2)曾在 vLLM 的 csrc/attention/ 里,当前 main 分支已移除,只剩设计文档 docs/design/paged_attention.md;如今 V1 主要使用 FlashAttention、FlashInfer 等库提供的 paged KV 接口(vllm/v1/attention/backends/)。「paged KV」已经成为 attention 库的标准输入格式。
V1 里的共享方式变了 :parallel sampling(n>1)被拆成 n 个子请求(vllm/v1/engine/parallel_sampling.py),靠 prefix caching 共享 prompt 中已写满的 block,而不是单独的 CoW 逻辑。思想相同:同样内容的 block 只存一份,用引用计数管理生命周期。
SGLang 的 python/sglang/srt/mem_cache/memory_pool.py 用 req_to_token 映射表 + token 粒度的 KV pool 达到同样目的,page size 可配置。
吞吐收益 :论文中与 FasterTransformer、Orca 相比吞吐提升 2–4 倍 ;在 ShareGPT 上,同等延迟下能承受的请求速率是 Orca (Oracle)(论文假设 Orca 事先知道每个请求真实输出长度、按需精确预留的理想版本)的 1.7–2.7 倍。
💬 能聊的要点
PagedAttention 就是把 OS 分页用在 KV Cache 上:固定大小的 block、block table 做逻辑到物理的映射、按需分配,消除了外部碎片,内部碎片限制在最后一个 block,显存利用率从 20–40% 提到 96%。
它需要专门的 attention kernel 按 block table 读取非连续 KV,论文(2023)里的初版 kernel 本身比连续布局慢约 20–26%,但换来的 batch size 提升让端到端吞吐高 2–4 倍。
block 加引用计数后,可以跨序列共享 KV:parallel sampling、beam search 用 copy-on-write,prefix caching 也建立在同一套 block 机制之上。
常见误区
❌ 「PagedAttention 让 attention 算得更快」 ✅ 论文里的 attention kernel 本身反而略慢(如今 FlashAttention/FlashInfer 的 paged 版本已把这点开销压得很小);收益来自显存利用率提升带来的更大 batch。
❌ 「PagedAttention 减小了 KV Cache 的大小」 ✅ 每 token 的 KV 字节数不变(那是 GQA/MLA/量化做的),它减少的是浪费 。
❌ 「block size 越小越好,碎片越少」 ✅ 太小会让 block table 变长、访存更碎、kernel 效率下降;vLLM 默认选 16 是实验结果。
自测(心算/口答即可)
block size=16,一个请求当前有 100 个 token,占几个 block?浪费几个 slot?答案 ceil(100/16) = 7 个 block,共 112 个 slot,浪费 12 个。
为什么 PagedAttention 没有 external fragmentation?答案 所有物理 block 大小相同,且请求不要求连续的物理空间,任意空闲 block 都能被任意请求使用。
beam search 为什么比 parallel sampling 从共享中省得更多?答案 parallel sampling 只共享 prompt;beam search 的各个候选还共享大量已生成的前缀,并且随着 beam 的分叉和淘汰动态共享,所以共享比例更高(论文中 37.6–55.2% vs 6.1–9.8%)。
延伸
🎬 Karpathy:无直接对应视频
🎬 李沐:无直接对应视频;可复习操作系统课程中的虚拟内存/分页
📄 Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (SOSP 2023, arXiv:2309.06180);vLLM 博客 vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention (2023);源码 vllm/v1/core/block_pool.py
下一课 → 39 · Continuous Batching 与调度:吞吐翻倍的秘密
39 · Continuous Batching 与调度:吞吐翻倍的秘密
⏱ 10 分钟 | 前置:38 · PagedAttention | 难度:★★☆
一句话
Continuous batching (也叫 iteration-level scheduling,来自 Orca)把调度粒度从「一个请求」改成「一次 forward(一个 token step)」:每一步都可以让结束的请求离开、让新请求加入;再配合 chunked prefill 把长 prompt 切块混进 decode batch,以及显存不够时的 preemption (swap 或 recompute),GPU 就能始终保持满载。
为什么要关心
第 36 课说过,decode 吞吐完全依赖 batch 大小;第 38 课的 PagedAttention 解决了「显存放得下多少请求」。但如果调度方式不对,显存空出来了也用不上。Continuous batching 是 vLLM、SGLang、TensorRT-LLM(叫 in-flight batching)、TGI 的共同基础,也是 LLM serving 和传统 model serving(按请求攒 batch)最大的区别。
核心直觉
1. Static batching 的问题
传统做法(包括早期 FasterTransformer 的用法):攒够一批请求 → 一起跑到所有请求都结束 → 再接下一批。
static batching(4 个 slot,时间 →)
slot0: A A A A A A A A A A A A ← A 输出很长
slot1: B B B · · · · · · · · · ← B 早结束,slot 空转
slot2: C C C C C · · · · · · ·
slot3: D D · · · · · · · · · ·
新请求 E、F:在队列里等整批结束才能开始
LLM 的输出长度差别很大(从几个 token 到几千个),所以 static batching 下大部分 slot 都在空转,而且短请求要陪长请求等到最后,延迟也很差。
2. Iteration-level scheduling(Orca, OSDI 2022)
Orca 的想法:调度器每次只让引擎跑一个 iteration (生成一个 token),然后重新决定 batch 的组成。
continuous batching
slot0: A A A A A A A A A A A A
slot1: B B B E E E E E E G G G ← B 一结束,E 立刻补上
slot2: C C C C C F F F F F F F
slot3: D D H H H H I I I I I I
要让「每一步 batch 的组成都不同」可行,还有一个问题:不同请求的序列长度不同,attention 无法直接拼成一个规整的 tensor。Orca 的第二个技巧是 selective batching :把 batch 里所有 token 展平成 [总 token 数, hidden](hidden = 模型隐藏维度,如 4096),线性层(QKV 投影、FFN)对展平后的 token 一起做 (它们不关心 token 属于哪个请求);只有 attention 按请求分开算。今天的 varlen(variable-length,变长序列)/ paged attention kernel 直接接受「展平的 token + 每个请求的长度/ block table」,让 attention 也能在一次 kernel 调用里完成。
论文结果:GPT-3 175B 上,相同延迟下吞吐是 FasterTransformer 的 36.9 倍 。
3. Chunked prefill:不让长 prompt 卡住 decode
Continuous batching 的新问题:一个 10K token 的 prompt 加入时,这一步 forward 要做完整的 prefill(compute-bound,可能几百毫秒),同 batch 里所有正在 decode 的请求这一步都被拖慢——用户会感到输出突然卡顿(TPOT/ITL 出现尖刺)。
Chunked prefill (Sarathi / Sarathi-Serve, OSDI 2024):给每一步设一个 token budget (如 2048),把长 prompt 切成多个 chunk,每一步只处理一个 chunk,剩余的 budget 给 decode token 用:
token budget = 2048 / step
step1: [decode×60] + [P 的 chunk1: 1988 tokens]
step2: [decode×60] + [P 的 chunk2: 1988 tokens]
...
每步耗时稳定 → decode 不会卡顿;prefill 的 GEMM 与 decode 的 GEMV 合并,算力也用上了
Sarathi-Serve 称其为 stall-free batching :新请求加入时不暂停正在进行的 decode。论文报告与 vLLM 相比,Mistral-7B(单卡 A100)serving capacity 提升 2.6 倍,Yi-34B 提升 3.7 倍。
取舍:chunk 越小,TPOT 越平稳,但 prefill 被拆成更多步,TTFT 变长,每步的固定开销也更多。
🧩 难点拆解:iteration-level scheduling + chunked prefill 走一遍
直觉 :static batching 像旅游大巴——坐满才发车,全车人都到终点才接下一批。iteration-level scheduling 像地铁——每一站(每个 token step)都有人下车、有人上车。chunked prefill 则是规定「每站最多上 N 个人」,一个 1 万人的旅行团要分几站上完,免得一站停太久,车上的人全被耽误。
小例子 (Llama-3-8B,H100;粗略按 50% 算力利用率约 500 TFLOPS 估算,忽略 attention FLOPs):60 个请求正在 decode,这时来了一个 10,000 token 的 prompt P。
做法
各 step 的 token 数
每步耗时(约)
60 个老用户看到的
不切块
… 60 → 10,060 → 61 …
6 ms → 约 320 ms → 6 ms
输出突然卡顿 0.3 秒
chunked,budget 2048
… 60 → 2048 ×5 → 120 → 61 …
6 ms → 约 65 ms ×5 → 约 6 ms
5 步都略慢,但没有大卡顿
算法:一步的 FLOP ≈ 2 × 8e9 × 本步 token 数。10,060 个 token ≈ 161 TFLOP → 约 320 ms;2048 个 token ≈ 33 TFLOP → 约 65 ms。P 被切成 5 块 1988 token + 最后 60 token。
逐步看调度器每一步做什么:
先给 running 里的 60 个 decode 请求各分 1 个 token(共 60)。
budget 还剩 2048 − 60 = 1988 → 分给 P,P 的 num_computed_tokens 从 0 → 1988。
下一步重复:P 从 1988 → 3976 → … → 9940 → 10000,最后一块算完才产出 P 的第 1 个 token。
期间任何请求输出 EOS,下一步它就不在 batch 里了,空出的 budget 和 KV block 立刻给别人。
代价也看得见:P 的 TTFT 从约 320 ms 变成约 5 × 65 = 325 ms 以上(多了几步的固定开销),换来老用户的 ITL 最大值从 320 ms 降到 65 ms。
一句话 :每一步都重新决定「谁上车、各上几个 token」,decode 是分到 1 个 token、prefill 是分到一大块,budget 限制了单步最多多重。
4. Preemption:显存不够时怎么办
Continuous batching 会尽量多放请求,但输出长度未知,运行中 KV 可能不够。这时调度器必须抢占 某些请求,释放它们的 block:
方式
做法
代价
Swap
把被抢占请求的 KV block 拷到 CPU 内存,之后拷回
占用 PCIe 带宽;block 小时传输很碎
Recompute
直接丢掉 KV,之后把「prompt + 已生成 token」当作新 prompt 重新 prefill
额外算力;但 prefill 是 compute-bound,重算一次通常很快
vLLM 论文 §7.3:block 较小时 recompute 更好,block 较大时 swap 更好,16–64 之间两者差不多。抢占策略是 all-or-nothing(一个请求的 block 要么全留,要么全踢),并且后到的先被抢占 ,保证公平。
🧩 难点拆解:Preemption(抢占)走一遍
直觉 :像停车场满了、而已经在场内的车还要「变长」(每 decode 一步多占一格)。管理员只能请最后进场的那辆车先出去,腾出车位;它之后要么把车上的东西暂存到场外仓库(swap 到 CPU),要么干脆清空、回头从头装一遍(recompute)。
小例子 :KV 池共 100 个 block(block size 16)。running 按到达顺序是 A(40 block)、B(35)、C(25),刚好用满。
这一步 A 的最后一个 block 写满,需要第 41 个 block → 空闲池为 0 → allocate_slots 失败。
调度器抢占 running[-1],也就是最后到的 C:释放它的 25 个 block,C 放回 waiting 队首。
A 拿到 1 个新 block 继续 decode;B 照常。空闲池剩 24 个。
之后 A 或 B 结束,block 充足了,C 重新被调度:
recompute (vLLM V1 的做法):C 已有 25 × 16 = 400 个 token(prompt + 已生成),当作新 prompt 重做一次 prefill,约 2 × 8e9 × 400 ≈ 6.4 TFLOP,在 H100 上约 10 ms 量级的算力;若开了 prefix caching 且 C 的 block 还没被别人覆盖,还能直接命中。
swap :抢占时把 400 × 128 KB = 50 MB 拷到 CPU 内存,恢复时拷回;PCIe 5.0 上单程约 1–2 ms,但要占 CPU 内存、管理两套地址,block 小时拷贝很碎。
C 的用户只会感到「卡了一下」,请求不会失败。
一句话 :显存不够时按「后到先走」整请求地踢出去,之后用重算或换入恢复;抢占频繁就说明 KV 容量配得太紧。
代码片段
vLLM V1 风格的调度循环(大幅简化):
def schedule(running, waiting, kv, token_budget=2048):
scheduled = {} # req -> 本步处理多少 token
# 1) 先照顾正在跑的请求(decode 或未完成的 chunked prefill)
for req in list(running):
if req not in running: continue # 本步已被当作 victim 抢占
n = min(req.num_tokens - req.num_computed_tokens, token_budget)
while not kv.allocate_slots(req, n): # 显存不够
victim = running.pop() # 抢占最后加入的请求
kv.free(victim)
victim.num_computed_tokens = 0 # V1:recompute,之后重新 prefill
waiting.appendleft(victim)
if victim is req: break
else:
scheduled[req] = n; token_budget -= n
# 2) 还有 budget 就从 waiting 里接新请求,长 prompt 自动被截成 chunk
while waiting and token_budget > 0:
req = waiting[0]
n = min(req.num_tokens - req.num_computed_tokens, token_budget) # chunked prefill
if not kv.allocate_slots(req, n): break
waiting.popleft(); running.append(req)
scheduled[req] = n; token_budget -= n
return scheduled # 一次 forward 执行这些 token
V1 调度器没有明确的「prefill 阶段 / decode 阶段」:每个请求只有 num_computed_tokens 和 num_tokens,调度器每步给它分配一定数量的 token。decode 是「分到 1 个 token」,prefill 是「分到很多个 token」,chunked prefill 自然成立。
victim.num_computed_tokens = 0 对应 vLLM V1 的实现:抢占即释放 block,之后重新计算(若开启 prefix caching,未被驱逐的 block 还能命中)。
token_budget 对应 --max-num-batched-tokens,另一个上限是 --max-num-seqs(同时运行的请求数)。
工程视角 / 真实世界
vLLM V1 :vllm/v1/core/sched/scheduler.py 的 Scheduler.schedule(),chunked prefill 默认开启(enable_chunked_prefill=True);_preempt_request() 中把请求状态设为 PREEMPTED,并把 num_computed_tokens 置 0。按 FCFS(First-Come-First-Served,先到先服务)策略时抢占 running[-1],按 priority 策略时抢占优先级最低的请求。
SGLang :调度器在 python/sglang/srt/managers/scheduler.py,chunked prefill 由 --chunked-prefill-size 控制;显存不足时调用 ScheduleBatch.retract_decode() 退回部分 decode 请求,并动态调整 new_token_ratio(预估未来会生成多少 token,用来决定一次接多少新请求)。
TensorRT-LLM 把同样的机制叫 in-flight batching ;Hugging Face TGI 也在早期就支持了 continuous batching。
监控要点 :vLLM 的 num_preemptions 指标持续增长,说明 KV 不够、请求被反复踢出重算,应当降低 max-num-seqs、加卡或开启 KV 量化;TPOT 有尖刺,就检查 chunked prefill 的 budget 设置。
行业数据:Anyscale 的博客 How continuous batching enables 23x throughput in LLM inference while reducing p50 latency (2023)是常被引用的工程对比。
💬 能聊的要点
Continuous batching 把调度粒度从请求降到 iteration:每个 token step 都能让结束的请求离开、新请求加入,消除了 static batching 中「短请求陪长请求等」造成的空转;线性层对展平的 token 统一计算,只有 attention 按请求区分(Orca 的 selective batching)。
Chunked prefill 用固定 token budget 把长 prompt 切块和 decode 混在一起,让每步耗时稳定、TPOT 不出现尖刺,同时让 decode 的空闲算力被 prefill 用上;代价是 TTFT 略增。
显存不够时靠 preemption:swap 到 CPU 或 recompute。vLLM 论文实验里两者在常用 block size(16–64)下性能相近;V1 只做 recompute,因为它逻辑简单、不占 CPU 内存和 PCIe,还能借 prefix caching 命中尚未被覆盖的 block。
常见误区
❌ 「continuous batching 就是动态攒 batch(dynamic batching)」 ✅ dynamic batching 仍然是请求级:攒一批后跑到结束;continuous batching 每个 token step 都会重组 batch。
❌ 「chunked prefill 能减少计算量」 ✅ 总 FLOPs 基本不变(后面的 chunk 要重复读取前面 chunk 已缓存的 KV,增加的是显存读取量而不是 FLOPs),它改变的是调度方式 ,换取延迟稳定性和更好的算力利用。
❌ 「被抢占的请求会失败」 ✅ 被抢占的请求回到等待队列,之后恢复;用户只感到变慢,不会报错。
自测(心算/口答即可)
static batching 下,一批 8 个请求,其中 7 个输出 50 token,1 个输出 1000 token,slot 利用率大约多少?答案 总共有效 token step = 7×50 + 1000 = 1350,占用 slot×step = 8×1000 = 8000,利用率约 17%。
为什么线性层可以跨请求拼 batch,而 attention 需要区分请求?答案 线性层对每个 token 独立计算,与 token 属于哪个请求无关;attention 要让 token 只关注同一请求中的 token,而且每个请求的 KV 长度不同。
token budget 从 2048 调到 512,TTFT 和 TPOT 分别怎么变?答案 长 prompt 要被切成更多 chunk,TTFT 变长;每步工作量更小,decode 的 TPOT 更低、更平稳。
延伸
🎬 Karpathy:无直接对应视频
🎬 李沐:无直接对应视频
📄 Yu et al., Orca: A Distributed Serving System for Transformer-Based Generative Models (OSDI 2022);Agrawal et al., Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve (OSDI 2024, arXiv:2403.02310);源码 vllm/v1/core/sched/scheduler.py
下一课 → 40 · FlashAttention:IO 感知的注意力 kernel
40 · FlashAttention:IO 感知的注意力 kernel
⏱ 10 分钟 | 前置:39 · Continuous Batching 与调度 | 难度:★★★
一句话
FlashAttention 不减少 attention 的计算量,而是减少 HBM 读写:把 Q、K、V 切成 tile 放进 SRAM,用 online softmax 边算边合并,从不把 N×N 的 attention 矩阵写回 HBM ;结果是精确的 attention(不是近似),显存从 O(N²) 降到 O(N),速度快数倍。
为什么要关心
它是「IO-aware(IO 感知)」这个思想最成功的例子:在 memory-bound 的世界里,少搬数据比少算更重要。今天几乎所有训练和推理框架的 attention 都基于 FlashAttention 或同类 kernel(FlashInfer、cuDNN SDPA),长上下文训练能做到 128K 以上也依赖它。理解 tiling + online softmax,也是看懂 FlashDecoding、PagedAttention kernel、Ring Attention 的前提。
核心直觉
1. 标准 attention 的 IO 问题
标准实现(每一步都是一个独立 kernel,都要来回 HBM):
S = Q @ Kᵀ 读 Q,K → 写 S [N×N] 到 HBM
P = softmax(S) 读 S → 写 P [N×N] 到 HBM
O = P @ V 读 P,V → 写 O [N×d]
以 N=8K、单个 head、BF16 为例,S 就是 8192² × 2 bytes = 128 MB,每个 head、每层都要写一遍再读一遍。而 softmax、mask、dropout 都是逐元素操作,算术强度极低(第 35 课),时间几乎全花在搬这个 N×N 矩阵上。更严重的是显存:N×N 随序列长度平方增长,长上下文直接 OOM。
2. Tiling:算完就丢,不写回
FlashAttention 把整个 attention 融合成一个 kernel :
外层:每个 thread block 负责一块 Q_i(如 128 行),加载进 SRAM
内层:依次把 K_j, V_j(如 128 行)加载进 SRAM
S_ij = Q_i @ K_jᵀ ← 只在 SRAM 里,128×128
更新 running max / sum(online softmax)
acc_i = acc_i * 修正系数 + softmax 部分 @ V_j
最后:O_i = acc_i / sum,写回 HBM(只写 N×d)
N×N 的矩阵从未完整存在过:每个 tile 在 SRAM 里用完就丢。HBM 读写从 O(N²) 降到约 O(N²d²/M)(d 是 head_dim,常见 64–128;M 是 SRAM 能放的元素数,约十万量级),而 M 远大于 d²(如 128² ≈ 1.6 万),因此实际 IO 远少于标准实现。
类比:标准实现像先把两张大表 join 的完整结果落盘、再扫一遍做聚合;FlashAttention 像在内存里边 join 边聚合(流式聚合),中间结果从不落盘。
3. 难点:softmax 需要整行信息
softmax 分母要对整行求和,数值稳定版本还要先减去整行最大值。只看到一部分 K 时怎么办?Online softmax (Milakov & Gimelshein, 2018):维护当前的最大值 m 和归一化和 l,遇到新块时,如果最大值变大,就把之前累积的结果乘以 exp(m_old − m_new) 进行修正。这个修正是精确的,所以 FlashAttention 的结果与标准 attention 在数学上等价(仅有浮点误差)。
🧩 难点拆解:tiling + online softmax,用两个块的真实数字走一遍
直觉 :像流式计算加权平均。你在读一个很长的日志流,要算「按分数加权的平均值」,但内存只放得下一小段。办法是:维护三个累加器(当前最大分数 m、权重和 l、加权和 acc),每读一段就更新;如果新一段里出现了更大的分数,就把之前的累加器统一「换算」到新基准上。这和 MapReduce 里先局部聚合、再合并的思路一样,关键是这个聚合可以精确合并。
小例子 :1 个 query,4 个 key,分成 2 个 block,每块 2 个。为了好算,value 取标量(d=1)。
Block 1: scores s = [1, 2] values v = [10, 20]
Block 2: scores s = [4, 0] values v = [30, 40]
处理 Block 1:
m = max(1, 2) = 2
p = exp(s − m) = [e^-1, e^0] = [0.368, 1]
l = 0.368 + 1 = 1.368
acc = 0.368×10 + 1×20 = 23.68
处理 Block 2(出现了更大的分数 4):
m_new = max(2, 4) = 4,修正系数 scale = exp(2 − 4) = 0.135
新块:p = exp([4−4, 0−4]) = [1, 0.018]
l = 1.368 × 0.135 + (1 + 0.018) = 0.185 + 1.018 = 1.203
acc = 23.68 × 0.135 + (1×30 + 0.018×40) = 3.20 + 30.73 = 33.94
最后:O = acc / l = 33.94 / 1.203 ≈ 28.20
对照标准做法:一次性对 [1, 2, 4, 0] 做 softmax 再加权,(e¹·10 + e²·20 + e⁴·30 + e⁰·40) / (e¹+e²+e⁴+e⁰) = 1852.9 / 65.70 ≈ 28.20,完全一致。
为什么乘 0.135 就对了:Block 1 的权重是按 exp(s − 2) 算的,想换成 exp(s − 4),每一项都要乘 exp(2 − 4);l 和 acc 都是这些项的线性和,所以整体乘一次即可。
放回 FlashAttention:真实 kernel 里一个 thread block 负责 128 行 query,每行各有一组 (m, l, acc),每次从 HBM 读进一块 K/V(如 128 行)到 SRAM,做完上面的更新就丢掉这块,再读下一块。整个过程中 N×N 的分数矩阵只以 128×128 的小块短暂存在于 SRAM。
一句话 :softmax 虽然需要整行信息,但「最大值 + 和 + 加权和」这三个量可以分块累加、遇到新最大值时乘一个修正系数,所以 attention 能边读边算,不落 HBM。
4. Backward:宁可重算,也不存
训练时 backward 需要 P。FlashAttention 不存 N×N 的 P,只存每行的 softmax 统计量(logsumexp,即 m + log(l),一行一个数,共 O(N)),backward 时从 Q、K 重算 S 和 P。FLOPs 反而增加了,但速度更快——这是「IO 比 FLOPs 贵」的直接体现。
5. 三代演进
版本
目标硬件
关键改进
论文报告的效果
FA1 (Dao et al., NeurIPS 2022)
A100
tiling + online softmax + backward 重算
BERT-large 端到端比 MLPerf 1.1 纪录快 15%;GPT-2(1K 长度)快 3 倍
FA2 (Dao, 2023)
A100
减少非 matmul FLOPs(归一化推迟到最后);沿序列长度方向也并行;warp 间按 Q 分工,减少 shared memory 通信
约为 FA1 的 2 倍,达到 A100 理论峰值的 50–73%,训练 GPT 时达 225 TFLOPs/s 每卡
FA3 (Shah et al., 2024)
H100
利用 Hopper 的异步特性:warp specialization(生产者 warp 用 TMA——硬件异步拷贝单元——搬数据,消费者 warp 用 WGMMA——Hopper 的异步矩阵乘指令——计算);softmax 与 GEMM 交错重叠;FP8 block quantization
比 FA2 快 1.5–2.0 倍;FP16 达 740 TFLOPs/s(75% 利用率),FP8 接近 1.2 PFLOPs/s
2026 年 3 月又发布了面向 Blackwell(B200/GB200)的 FlashAttention-4 (arXiv:2603.05451),针对 Tensor Core 翻倍但 exp 单元、shared memory 带宽没有同步提升的问题,重新设计流水线,并用软件模拟部分 exp 计算。
规律:每一代都在针对新硬件的瓶颈做调整——FA1 解决 HBM IO,FA2 解决 GPU 并行度和非 matmul 开销,FA3/FA4 利用新架构的异步单元,并处理 softmax 中 exp 等非 matmul 运算逐渐成为瓶颈的问题。
代码片段
Online softmax 的核心(单个 query 行,按块扫 K/V):
import torch
def online_softmax_attention(q, K, V, block=128):
# q: [d],K/V: [N, d];只保留 O(d) 的状态,不存长度为 N 的分数向量
m = torch.tensor(float("-inf")) # 到目前为止的最大 score
l = torch.tensor(0.0) # 到目前为止的 sum(exp(s - m))
acc = torch.zeros_like(q) # 到目前为止的 sum(exp(s - m) * v)
for j in range(0, K.shape[0], block):
s = K[j:j+block] @ q / q.shape[0] ** 0.5 # 这一块的 scores
m_new = torch.maximum(m, s.max())
scale = torch.exp(m - m_new) # 旧结果的修正系数
p = torch.exp(s - m_new) # 这一块的未归一化概率
l = l * scale + p.sum()
acc = acc * scale + p @ V[j:j+block]
m = m_new
return acc / l # 最后再统一归一化(FA2 的做法)
# 与标准实现对比:结果一致
q, K, V = torch.randn(64), torch.randn(1000, 64), torch.randn(1000, 64)
ref = torch.softmax(K @ q / 8, dim=0) @ V
print(torch.allclose(online_softmax_attention(q, K, V), ref, atol=1e-5)) # True
scale = exp(m - m_new):当新块中出现更大的 score 时,把之前的 l 和 acc 缩小到同一基准上,这是 online softmax 的全部技巧。
状态只有 m、l、acc 三个,大小与 N 无关——这就是能在 SRAM 里完成的原因。
真实 kernel 中外层还会对 Q 分块(一次处理 128 行 query),K[j:j+block] @ q 变成 tile 与 tile 的矩阵乘,交给 Tensor Core。
工程视角 / 真实世界
PyTorch :torch.nn.functional.scaled_dot_product_attention 会自动选择 FlashAttention / memory-efficient / cuDNN 后端。Karpathy 在 GPT-2 复现视频中把手写 attention 换成这一行,速度明显提升。
推理 decode 的特殊性 :decode 时每个请求的 query 长度只有 1,按 Q 分块并行度不够;Flash-Decoding (Tri Dao 等人 2023 年的博客)改为沿 KV 长度方向切分 ,多个 thread block 各算一段,最后用同样的 (m, l) 修正规则合并——这正是 online softmax 可合并性的另一种应用。
与 PagedAttention 的结合 :推理框架需要 kernel 直接读取 paged KV。vLLM 使用自己维护的 FlashAttention 分支(vllm-project/flash-attention)以及 FlashInfer,后端代码在 vllm/v1/attention/backends/flash_attn.py、flashinfer.py;SGLang 在 python/sglang/srt/layers/attention/ 下有 flashattention_backend.py、flashinfer_backend.py、triton_backend.py 等多种后端。
MLA 等变体 :DeepSeek 的 MLA 需要专门的 kernel(FlashMLA),也是在同样的 tiling + online softmax 框架上针对 MLA 的形状做优化。
💬 能聊的要点
FlashAttention 是精确 attention,FLOPs 不减反增(backward 重算),快的原因是 IO-aware:tiling 把 Q/K/V 分块放进 SRAM,融合成一个 kernel,N×N 的矩阵从不写回 HBM,显存从 O(N²) 降到 O(N)。
分块能成立的数学基础是 online softmax:维护 running max 和 running sum,遇到更大的 max 时用 exp(m_old − m_new) 修正已有结果;同一规则也用于 Flash-Decoding 沿 KV 方向切分后的结果合并。
三代的主线:FA1 解决 HBM IO,FA2 解决并行度和非 matmul 开销(A100 上达到 50–73% 峰值),FA3 利用 Hopper 的 TMA/WGMMA 异步和 FP8(H100 上 FP16 达 740 TFLOPs/s)。
常见误区
❌ 「FlashAttention 是一种近似 attention」 ✅ 它是精确计算,与标准 attention 数学等价;近似的是 Linformer、Performer 这类方法。
❌ 「FlashAttention 降低了 attention 的 O(N²) 复杂度」 ✅ 计算仍然是 O(N²),降低的是 显存 (O(N²) → O(N))和 HBM IO 。
❌ 「有了 FlashAttention,推理就不需要 KV Cache 管理了」 ✅ 两者解决不同的问题:FlashAttention 优化单次 attention 计算的 IO,PagedAttention 管理跨 step 的 KV 存储;推理中二者组合使用。
自测(心算/口答即可)
N=16K,32 个 head,BF16,标准实现中一层的 S 矩阵占多少显存?答案 16384² × 2 bytes = 512 MB 每个 head,32 个 head 共 16 GB(batch=1)。FlashAttention 不需要这部分。
online softmax 中,为什么遇到更大的最大值时,要把 acc 乘以 exp(m_old − m_new)?答案 之前的项都是按 exp(s − m_old) 累积的,要改用新的基准 m_new,每一项都需要乘以 exp(m_old − m_new),这样所有块的结果才在同一基准上,可以直接相加。
为什么 backward 选择重算 P,而不是保存 P?答案 保存 P 需要 O(N²) 显存,并且要从 HBM 读回;从 SRAM 中的 Q、K tile 重算虽然多了 FLOPs,但 attention 在这里是 IO-bound,重算反而更快,同时省下显存。
延伸
🎬 Karpathy:Let's reproduce GPT-2 (124M) 中「flash attention」一节(约 02:00:18),用 F.scaled_dot_product_attention 替换手写 attention
🎬 李沐:无直接对应视频;d2l 第 12 章「计算性能」可作为背景
📄 Dao et al., FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness (NeurIPS 2022, arXiv:2205.14135);Dao, FlashAttention-2 (arXiv:2307.08691);Shah et al., FlashAttention-3 (arXiv:2407.08608)
下一课 → 40a · Kernel 实战:Profiling → Fusion → Triton → torch.compile
40a · Kernel 实战:Profiling → Fusion → Triton → torch.compile
⏱ 10 分钟 | 前置:35 · GPU 速成 、40 · FlashAttention | 难度:★★★ | 🆕 对标 Stanford CS336 Lecture 5–6(GPUs, Kernels, Triton)与 Assignment 2
一句话
优化一个慢模型的固定流程:先正确计时 → 用 profiler 找出时间花在哪 (通常是一堆 memory-bound 的小算子和 kernel launch 间隙)→ 用 fusion 把它们合并 ,可以交给 torch.compile 自动生成 Triton kernel,也可以手写 Triton,还可以用 CUDA Graph 消掉 launch 开销。FlashAttention 只是这套流程里最出名的一个手写 fused kernel。
为什么要关心
CS336 Assignment 2 的主线就是这条:先 benchmark / profile 自己的 Transformer,再用 Triton 手写 FlashAttention-2。Kernel / Compiler 也是 AI Infra 里最硬核、最缺人的方向(附录 career-map)。
vLLM、SGLang 里有大量 Triton kernel(如 fused MoE、部分 attention 后端),读懂 Triton 才能读懂这些代码。
就算不写 kernel,「为什么这个模型 MFU 只有 20%」「为什么开了 torch.compile 快了 30%」这类问题,你也要能定位到算子层面。
核心直觉
1. 第一步:别把计时测错
CUDA 是异步 的:Python 调 A @ B 只是把 kernel 放进队列就返回,GPU 可能还没开始算。常见三个坑:
不 torch.cuda.synchronize() 就读时间 → 测到的是「排队用了多久」;
不预热 → 第一次调用包含 CUDA 上下文初始化、cuBLAS 选算法、torch.compile 编译;
只跑一次 → 抖动很大。用 torch.utils.benchmark 或 CUDA event 多次取中位数。
2. 第二步:Profile,看时间到底花在哪
torch.profiler 能按 kernel 列出 GPU 时间;Nsight Systems(nsys)给时间线,能看到 kernel 之间的空隙(第 35 课)。对一个朴素实现的 Transformer block,典型发现是:
GPU 时间线(示意)
███████ matmul ███████ ▏norm▕▏mul▕▏add▕▏silu▕▏mul▕ ███████ matmul ███████ ▏add▕ ...
└ compute-bound,接近峰值 ┘ └ 一串 memory-bound 小算子 + launch 间隙 ┘
矩阵乘已经由 cuBLAS 做到峰值的 60–80%,没什么可抠;时间常常漏在矩阵乘之间那一串逐元素 / 归约算子 上:每个算子都要把整个张量从 HBM 读一遍、写一遍,算术强度只有个位数(第 35 课),GPU 大部分时间在搬数据。
3. 第三步:Fusion,把多次 HBM 往返变成一次
Kernel fusion 的思路和 FlashAttention 一样(第 40 课):把几个算子合进一个 kernel,中间结果留在寄存器 / SRAM 里,只在开头读一次、结尾写一次。
🧩 难点拆解:RMSNorm 融合能省多少
直觉 :memory-bound 算子的耗时 ≈ 搬运字节数 ÷ 带宽,所以省多少时间就看省了几次「整张量往返 HBM」。
代入数字 :输入 x 形状 [8192 token, 4096],BF16,大小 64 MiB。H100 带宽 3.35 TB/s。
朴素写法 x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps) * w:pow 读 x 写 x²,mean 读 x²,x * r 读 x 写结果,* w 再读一遍写一遍。粗算约 6 次整张量的读写 ≈ 384 MiB 流量 → 约 120 µs ,还不算 5 次 kernel launch。
Fused kernel :每行读一次 x、在寄存器里算平方和、rsqrt、乘 w,写一次结果 = 128 MiB → 约 40 µs ,一次 launch。
实际差距常常更大:朴素写法为了数值稳定会先把 x 转成 FP32,中间张量大小翻倍。
一句话 :对 memory-bound 算子,优化 = 数清 HBM 往返次数并把它降到「读一次、写一次」。
4. Triton:以「一块数据」为单位写 kernel
CUDA 让你写「一个 thread 做什么」;Triton 让你写「一个 program 处理一块数据 做什么」(第 35 课)。一个 Triton kernel 的骨架永远是四步:
pid = tl.program_id(0) # 我是第几个 program(比如负责第几行)
offs = ... + tl.arange(0, BLOCK) # 我负责的那一块数据的下标
x = tl.load(ptr + offs, mask=...) # HBM → 寄存器 / SRAM(越界的用 mask 挡掉)
... 在块内随便算(max、exp、sum、dot)...
tl.store(out_ptr + offs, y, mask=...) # 寄存器 → HBM,只写一次
线程怎么映射、shared memory 怎么分配、访存怎么合并,交给编译器处理。用几十行 Python 就能写出接近手写 CUDA 的 fused kernel,代价是对极限优化(如 Hopper 的 TMA、warp specialization)的控制力弱一些。
5. torch.compile:让编译器替你做 fusion
你的 PyTorch 代码
│ TorchDynamo:拦截 Python 字节码,抓出计算图(遇到不支持的写法就 graph break)
▼
FX 计算图
│ Inductor:把逐元素 / 归约算子融合,生成 Triton kernel;
│ 矩阵乘仍交给 cuBLAS(max-autotune 模式下会试 Triton 模板)
▼
编译后的模型 mode="reduce-overhead" 还会用 CUDA Graph 把整段 launch 录下来重放
上面的 RMSNorm,torch.compile 通常能自动融合成一个 Triton kernel;设置环境变量 TORCH_LOGS="output_code" 就能看到它生成的 Triton 源码,这是学 Triton 的好教材。vLLM 也用 torch.compile 做融合,再配合分段 CUDA Graph(第 45 课)。
6. 什么时候必须手写
编译器想不到的算法改写 :FlashAttention 的 online softmax(第 40 课)不是「把几个算子拼起来」,而是换了一种计算顺序,编译器不会自动发明它。
和矩阵乘深度融合 :fused MoE(第 21 课:按 Expert 分组的矩阵乘)、量化 GEMM(第 43 课:边反量化边乘)、fused linear + cross-entropy。
吃满新硬件 :Hopper / Blackwell 的新特性往往先在 CUDA / CUTLASS(及其 Python 化的 CuTe DSL)里可用;DeepSeek 的 DeepGEMM、FlashMLA 都是这一层。社区还在涌现 TileLang、ThunderKittens 等更高层的 kernel DSL。
代码片段
Triton 官方教程里的 fused softmax(读懂即可):
import torch, triton, triton.language as tl
@triton.jit
def softmax_kernel(out_ptr, in_ptr, stride, n_cols, BLOCK: tl.constexpr):
row = tl.program_id(0) # 每个 program 负责一行
cols = tl.arange(0, BLOCK) # BLOCK 是 ≥ n_cols 的 2 的幂
mask = cols < n_cols
x = tl.load(in_ptr + row * stride + cols, mask=mask, other=-float("inf"))
x = x - tl.max(x, axis=0) # 减最大值,数值稳定(第 08 课)
num = tl.exp(x)
y = num / tl.sum(num, axis=0) # 整行都在寄存器里,不回 HBM
tl.store(out_ptr + row * stride + cols, y, mask=mask)
def softmax(x): # x: [n_rows, n_cols],在 GPU 上
n_rows, n_cols = x.shape
out = torch.empty_like(x)
softmax_kernel[(n_rows,)](out, x, x.stride(0), n_cols,
BLOCK=triton.next_power_of_2(n_cols))
return out
def naive_softmax(x): # 对照组:5 个 kernel,5 次 HBM 往返
z = x - x.max(dim=1, keepdim=True)[0]
num = torch.exp(z)
return num / num.sum(dim=1, keepdim=True)
fast_rms = torch.compile(lambda x, w: x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + 1e-6) * w)
softmax_kernel[(n_rows,)] 里的方括号是 launch grid :启动 n_rows 个 program,每个处理一行。这和第 40 课 FlashAttention 按「(batch, head, Q 块)」切 grid 是同一种思路。
每行只 tl.load 一次、tl.store 一次,max、exp、sum、除法都在寄存器里完成;对照组 naive_softmax 每一步都是一个独立 kernel、一次整张量往返。
注意:PyTorch 自带的 torch.softmax 本身已经是 fused kernel。手写的价值在于「softmax + 别的东西」一起融合,比如 FlashAttention 把 QKᵀ、mask、softmax、×V 融在一起。
最后一行:同样的融合交给 torch.compile,一行代码,大多数逐元素 / 归约组合都能自动搞定。
工程视角 / 真实世界
Fused linear + cross-entropy :Llama 3 的 vocab 是 128,256,一条 8K 序列的 logits 用 BF16 存就约 2.1 GB(8192 × 128256 × 2 B),FP32 翻倍。Liger Kernel 等库把最后的 Linear 和 cross-entropy 融合、按块计算,不物化完整 logits,训练显存大幅下降——这是「fusion 不只省时间,也省显存」的典型例子。
vLLM / SGLang 里的 Triton :vLLM 的 fused MoE(vllm/model_executor/layers/fused_moe/)有 Triton 实现,SGLang 有 triton_backend.py attention 后端(第 35 课)。它们是对照学习的好材料:同一个算子有 Triton、CUDA、外部库多种实现。
Autotune :Triton 的 @triton.autotune 会在首次运行时尝试多组 BLOCK 大小 / num_warps,挑最快的。线上服务要注意首次请求的编译和调优延迟,通常在启动时预热。
Profiling 是日常 :CS336 A2 要求先用 profiler 回答「forward / backward / optimizer 各占多少」「哪些 kernel 最耗时」,再动手优化。这个顺序在工作里同样适用:先测,再改 。
💬 能聊的要点
「Transformer 里矩阵乘通常已经接近峰值,时间常漏在中间的 norm、激活、残差这些 memory-bound 小算子上;优化方法是 fusion,把多次 HBM 往返压成一次读、一次写。」
「Triton 以一个 block 的数据为单位编程:program_id → load → 块内计算 → store,线程映射和 shared memory 交给编译器;torch.compile 的 Inductor 后端就是自动生成 Triton kernel 做融合,reduce-overhead 模式再加 CUDA Graph。」
「编译器能做算子拼接式的融合,但 FlashAttention 这种改了计算顺序的算法、和矩阵乘深度融合的 fused MoE / 量化 GEMM,还得手写。」
常见误区
❌ 用 time.time() 包住一行 GPU 代码就能测速。✅ CUDA 是异步的,要 synchronize、预热、多次取中位数,或用 CUDA event。
❌ 优化 GPU 程序就是减少 FLOPs。✅ 对 memory-bound 算子,FLOPs 几乎不重要,要减少的是 HBM 读写次数和 kernel launch 次数。
❌ 用了 torch.compile 就不需要手写 kernel。✅ 它擅长逐元素 / 归约融合;需要算法改写或深度利用新硬件的 kernel,仍要靠 Triton / CUDA 手写。
自测(心算/口答即可)
一个 [16384, 8192] 的 BF16 张量做「GELU + 加 bias」,朴素实现(两个独立 kernel)和融合实现在 H100 上各约多久?答案 张量 256 MiB。朴素:GELU 读写各一次、加 bias 读写各一次,约 1 GiB 流量 → 约 0.32 ms;融合:读一次写一次约 512 MiB → 约 0.16 ms,快一倍,还少一次 launch。
Triton kernel 的 launch grid (n_rows,) 是什么意思?答案 启动 n_rows 个 program 实例,每个通过 tl.program_id(0) 拿到自己的编号,处理对应的一行数据。
为什么 fused linear + cross-entropy 能省大量显存?答案 大词表下完整 logits(序列长度 × vocab)非常大,例如 8K × 128K 的 BF16 约 2 GB;融合后按块算 logits、立刻算 loss 和梯度再丢掉,不需要同时存下完整 logits。
延伸
🎓 Stanford CS336:Lecture 5(GPUs, TPUs)、Lecture 6(Kernels, Triton)、Assignment 2(Systems:profiling、Triton 版 FlashAttention-2、DDP)——见附录 CS336 对照与动手路线
🎬 Karpathy:Let's reproduce GPT-2 (124M) 中开启 torch.compile 与 flash attention 提速的部分
📖 Triton 官方教程:Vector Addition → Fused Softmax → Matrix Multiplication → Fused Attention(按顺序读)
📖 Horace He, Making Deep Learning Go Brrrr From First Principles (compute / memory / overhead 三分法,博文)
📄 Tillet et al. 2019, Triton: An Intermediate Language and Compiler for Tiled Neural Network Computations ;Ansel et al. 2024, PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation
🎬 GPU MODE(社区讲座,YouTube):CUDA / Triton / profiling 系列
下一课 → 41 · Prefix Caching 与 RadixAttention
41 · Prefix Caching 与 RadixAttention(SGLang 核心)
⏱ 10 分钟 | 前置:40 · FlashAttention | 难度:★★☆
一句话
只要两个请求的 token 前缀完全相同 ,它们在这段前缀上的 KV Cache 就完全相同,可以只算一次、跨请求复用;vLLM 的 Automatic Prefix Caching 用「block 内容 + 父 block」的链式哈希 实现,SGLang 的 RadixAttention 用基数树(radix tree)+ LRU 淘汰 + cache-aware 调度 实现,在多轮对话、few-shot、agent 场景下能省掉大部分 prefill。
为什么要关心
真实流量中大量 prompt 共享前缀:同一个 system prompt、同一组 few-shot 示例、多轮对话的历史、agent 每一步都带着之前全部的工具调用记录。每次都重新 prefill,既浪费算力,又拉高 TTFT。Prefix caching 几乎是「免费的吞吐」,也是各家 API 能提供「缓存命中的输入 token 价格更低」的技术基础。它也是 SGLang 相对 vLLM 早期版本最主要的差异点。
核心直觉
1. 为什么前缀能复用
第 37 课:因果 attention 下,位置 t 的 K、V 只依赖 token 0..t。所以只要 token[0..t] 相同(且模型、LoRA 等相同),这一段的 KV 就逐位相同。注意是前缀 :中间一段相同但前面不同,KV 也不同(因为前面的 token 通过 attention 影响了后面每一层的表示)。
请求 1: [System prompt 2000 tok][用户问题 A]
请求 2: [System prompt 2000 tok][用户问题 B] ← 前 2000 token 的 KV 可以直接复用
请求 3: [用户问题 A][System prompt 2000 tok] ← 顺序不同,无法复用
2. vLLM:Automatic Prefix Caching(hash-based)
建立在 PagedAttention 的 block 之上(第 38 课):
block_hash[i] = hash( block_hash[i-1], block i 的 16 个 token id, extra_keys )
↑ 父 block 的哈希 ↑ 本 block 的内容 ↑ LoRA 名、多模态输入哈希、cache_salt
全局表:block_hash → physical block
链式哈希 :包含父 block 的哈希,所以哈希相同 ⇔ 从开头到这个 block 的整段前缀相同,一次查表就能确认整个前缀。
只缓存写满的 block :最后一个未满的 block 不参与共享,因此命中粒度是 block size(默认 16 token)。
查找 :新请求从第 0 个 block 开始逐个算哈希、查表,直到第一次未命中;命中的 block 引用计数加 1 直接使用,prefill 从未命中的位置开始。
淘汰 (LRU = Least Recently Used,最久未用的先淘汰):请求结束后 block 的 ref_cnt(引用计数,第 38 课)归零,但内容和哈希保留 ,放进空闲队列尾部;分配新 block 时从队首取(LRU),这时才真正清除哈希。同一时间释放的 block 中,链尾(更长前缀)的先被淘汰。所以「空闲显存」本身就是缓存,不需要额外空间。
3. SGLang:RadixAttention(tree-based)
把所有缓存的 token 序列组织成一棵 radix tree (压缩前缀树):边上是一段 token 序列,节点关联这段 token 的 KV(SGLang 论文中 page size 等于 1 个 token)。
(root)
│ "You are a helpful assistant." ← system prompt,所有请求共享
┌─────────┴──────────┐
"User: 翻译…" "User: 写一首诗…"
│ │
"Assistant: …" "Assistant: …"
│ ├───────────────┐
(第二轮) "User: 再短点" "User: 改成五言" "User: 换个主题" ← 多轮对话自然形成分叉
match_prefix :沿树向下匹配新请求的 token,最长匹配部分直接复用。
insert :请求结束后(或 prefill 之后)把新的 token 和 KV 插入树中,必要时拆分边 。
LRU 淘汰 :显存不足时,优先淘汰最久未访问的叶子节点 ;叶子被淘汰后父节点可能成为叶子,继续按 LRU 淘汰。这样公共前缀(靠近根)最后才会被淘汰。
引用计数(lock_ref) :正在运行的请求所用的节点被锁住,不能淘汰。
🧩 难点拆解:哈希链 vs radix tree,同一组请求各走一遍
直觉 :哈希链像按「整页」建的数据库索引,key 是「从开头到这一页的全部内容」的指纹,只能整页命中;radix tree 像文件系统的目录树(或 trie),沿路径逐个字符往下走,走到哪算到哪,可以停在一页中间。
小例子 (为了画得下,block size = 4;数字代表 token id):
R1 = [1 2 3 4 | 5 6 7 8 | 9 10] 先到,跑完后缓存
R2 = [1 2 3 4 | 5 6 7 8 | 11 12 13 14 | 15]
R3 = [1 2 3 4 | 5 6 20 21 | 22]
vLLM 哈希链 :
R1:h0 = H(None, [1,2,3,4]),h1 = H(h0, [5,6,7,8]) 存进表;[9,10] 不满一块,不缓存。
R2:算 h0 → 命中;h1 → 命中;h2 = H(h1, [11..14]) → 未命中,停。复用 8 个 token,从第 8 个位置开始 prefill。
R3:h0 命中;第二块内容是 [5,6,20,21],哈希与 h1 不同 → 未命中。只复用 4 个 token——虽然 5 6 也相同,但不够一整块。
SGLang radix tree (page size = 1):
R1 之后: root ─[1 2 3 4 5 6 7 8 9 10]
R2 之后: root ─[1 2 3 4 5 6 7 8]─┬─[9 10]
└─[11 12 13 14 15] ← 在 8 后面拆边
R3 之后: root ─[1 2 3 4 5 6]─┬─[7 8]─┬─[9 10]
│ └─[11 12 13 14 15]
└─[20 21 22] ← 在 6 后面再拆一次
R2:沿树走,匹配到 8 个 token 后分叉 → 复用 8 个。
R3:匹配 1..6 共 6 个 token,比哈希链多 2 个。
淘汰:显存紧张时先删最久没用的叶子(比如 [9 10]),公共前缀 [1..6] 最后才删。
两者共同点:如果来一个 R4 = [99 1 2 3 4 ...],第一个 token 就不同,两种方案都一个也命中不了 ——因为只认从头开始的前缀。
一句话 :哈希链是「按整块查表」,O(1) 查一块、粒度是 block;radix tree 是「沿树逐 token 匹配」,粒度更细、天然表达树形分叉,代价是维护树结构。
4. Cache-aware scheduling:让缓存命中更高
只有缓存还不够,请求的执行顺序 也会影响命中率。如果先跑 A 家族、再跑 B 家族、再回到 A 家族,A 的前缀可能已经被淘汰了。SGLang 的做法是在等待队列中按匹配前缀长度排序,匹配越长越优先 (LPM, longest prefix match)。论文给出的定理:离线情况下(所有请求事先已知),按 radix tree 的 DFS(深度优先遍历,把同一棵子树的请求挨着跑完)顺序处理请求,并且缓存不小于最大请求长度时,命中率最优;LPM 是它的在线近似。
代价:一直优先命中多的请求,可能让冷门请求饿死,所以实际中需要和 FCFS 权衡。
5. 两种方案对比
vLLM APC
SGLang RadixAttention
数据结构
哈希表:block hash → block
radix tree
匹配粒度
完整 block(默认 16 token)
论文中为 token 级(page size 可配置)
淘汰
空闲 block 队列 LRU
叶子优先的 LRU
调度
默认 FCFS / priority,不主动按命中排序
可选 LPM / DFS-weight 等 cache-aware 策略
优势
与 block 管理天然统一,实现简单,查找 O(1)
能表达多层树形共享,结合调度命中率更高
两种实现的核心思想相同,差别主要在于数据结构和是否主动调度。现在两个框架都默认开启 prefix caching。
代码片段
vLLM 风格的链式 block 哈希与前缀查找(简化):
import hashlib, pickle
BLOCK = 16
cache = {} # block_hash -> physical_block_id(全局)
def block_hashes(token_ids, extra=None):
hashes, parent = [], b"none"
for i in range(0, len(token_ids) // BLOCK * BLOCK, BLOCK): # 只处理写满的 block
chunk = tuple(token_ids[i:i + BLOCK])
parent = hashlib.sha256(pickle.dumps((parent, chunk, extra))).digest()
hashes.append(parent) # 哈希里包含父哈希 → 等价于整段前缀
return hashes
def match_prefix(token_ids, extra=None):
hit = []
for h in block_hashes(token_ids, extra):
if h not in cache:
break # 第一个未命中就停:只能复用「前缀」
hit.append(cache[h])
return hit, len(hit) * BLOCK # 命中的物理 block,以及可以跳过 prefill 的 token 数
(parent, chunk, extra) 对应 vLLM 的 hash_block_tokens(hash_function, parent_block_hash, curr_block_token_ids, extra_keys)(vllm/v1/core/kv_cache_utils.py)。
extra 很重要:同样的 token,用不同的 LoRA adapter 或不同图片时 KV 不同,必须区分;cache_salt 可以让不同租户的缓存互相隔离,防止通过 TTFT 差异推测别人的 prompt(时序侧信道)。
用 sha256 这种强哈希而不是 Python 内置 hash,是为了避免哈希碰撞导致复用错误的 KV(当前 vLLM 默认 prefix_caching_hash_algo="sha256")。
对照看 SGLang 的 Radix 树匹配(简化自 python/sglang/srt/mem_cache/radix_cache.py 的 TreeNode / match_prefix):
class TreeNode:
def __init__(self):
self.children = {} # 边上第一个 token id -> 子节点
self.key = [] # 这条边上的 token 序列(可以很长,这就是 radix 的「压缩」)
self.value = [] # 与 key 等长:每个 token 的 KV 在显存池里的下标
self.lock_ref = 0 # >0:正被运行中的请求使用,不能驱逐
self.last_access_time = 0 # LRU 驱逐用
def match_prefix(root, tokens):
node, hit = root, []
while tokens and tokens[0] in node.children:
child = node.children[tokens[0]]
n = common_prefix_len(child.key, tokens) # 这条边能匹配多长
hit += child.value[:n]
child.last_access_time = now()
if n < len(child.key): # 只匹配了半条边:到此为止
break # (插入新请求时会在这里把边劈成两段)
node, tokens = child, tokens[n:]
return hit # 命中的 KV 下标,这些 token 不用再 prefill
和上面哈希链比:哈希链按整 block 命中、查表 O(1);radix 树沿树往下走,可以精确到单个 token (page_size=1 时),还天然知道「谁是谁的前缀」,方便做 cache-aware 调度。
驱逐只从 lock_ref == 0 的叶子 开始、按 last_access_time 做 LRU:叶子被删后父节点可能变成新叶子,公共前缀(如 system prompt)因为一直有子孙在用,最后才会被删。
工程视角 / 真实世界
vLLM 源码 :哈希在 vllm/v1/core/kv_cache_utils.py(hash_block_tokens、FreeKVCacheBlockQueue);缓存表和淘汰在 vllm/v1/core/block_pool.py(cached_block_hash_to_block、get_cached_block、cache_full_blocks)。V1 中 enable_prefix_caching 默认为 True。
SGLang 源码 :python/sglang/srt/mem_cache/radix_cache.py(TreeNode、RadixCache.match_prefix / insert / evict,淘汰时用 heapq 按 last_access_time 取叶子);调度策略在 python/sglang/srt/managers/schedule_policy.py(CacheAwarePolicy.LPM、DFS_WEIGHT)。注意:等待队列超过 128 个请求时,LPM 会退化为 FCFS,因为逐个匹配前缀并排序的开销太大。
论文数字 :SGLang(Zheng et al., NeurIPS 2024)在 agent、few-shot、JSON 解码、RAG、多轮对话等负载上报告最高 6.4 倍 吞吐提升;线上部署一个月观测到的缓存命中率为 LLaVA-Next-34B 52.4% 、Vicuna-33B 74.1% 。
典型场景 :
多轮对话:第 n 轮的 prompt = 前 n−1 轮全部内容 + 新问题,历史部分全部命中。
Few-shot:几十个示例固定放在前面,只有最后的问题不同。
Agent / tool use:每一步都带着之前全部的思考和工具输出,上下文只增不改,非常适合前缀缓存;不要在 prompt 开头放时间戳或随机 ID ,否则整个缓存都会失效。
Parallel sampling / self-consistency:同一 prompt 采样多次。
多副本场景 :每个实例只有自己的缓存,负载均衡器随机分发会破坏命中率,所以需要 cache-aware routing :SGLang 论文中 router 维护一棵 meta-tree(仓库中的 sgl-model-gateway);llm-d 等项目也为 vLLM 提供前缀感知路由。再往下是把 KV 卸载到 CPU/SSD/远端存储的多级缓存(LMCache、Mooncake),第 44 课会讲。
💬 能聊的要点
Prefix caching 成立的前提是因果 attention:相同前缀产生相同 KV,所以只能复用「从头开始的公共部分」;prompt 设计要把固定内容放前面、可变内容放后面。
vLLM APC 用链式哈希(父哈希 + 本 block token + LoRA/多模态等额外 key)把前缀映射到物理 block,释放的 block 保留内容作为缓存、按 LRU 淘汰,粒度是完整 block;SGLang 用 radix tree,叶子优先的 LRU 淘汰和引用计数,粒度可以细到 token。
SGLang 的另一个关键是 cache-aware scheduling:按最长前缀匹配排序等待队列,近似 radix tree 的 DFS 顺序,以提高命中率;多实例部署时,还需要前缀感知的路由,否则缓存会被负载均衡打散。
常见误区
❌ 「只要 prompt 中有一段相同的文字就能复用」 ✅ 必须是从第一个 token 开始的公共前缀;中间相同但前面不同的部分,KV 并不相同。
❌ 「prefix caching 需要额外的显存」 ✅ 它复用的是已经释放但尚未被覆盖的 KV block,显存紧张时会被 LRU 淘汰,不影响正常请求的容量。
❌ 「命中缓存后 decode 也会变快」 ✅ 它省的是 prefill(降低 TTFT、节省算力),decode 每一步仍需读取全部 KV,速度基本不变。
自测(心算/口答即可)
block size=16,两个请求共享前 1000 个 token,vLLM APC 最多能复用多少 token?答案 floor(1000/16) × 16 = 992 个 token(62 个完整 block),剩余 8 个 token 所在的 block 未写满/不完全相同,不能复用。
为什么 vLLM 的 block 哈希要包含父 block 的哈希?答案 因为同样的 16 个 token 出现在不同前缀之后时,KV 是不同的;包含父哈希后,哈希相等就等价于从开头到当前 block 的整段前缀相等。
为什么 RadixAttention 淘汰叶子节点,而不是任意节点?答案 靠近根的节点是更多请求共享的公共前缀,价值更高;而且子节点的 KV 依赖父节点,淘汰中间节点会使下面的子树无法使用。叶子优先保证公共前缀留到最后。
延伸
🎬 Karpathy:无直接对应视频
🎬 李沐:无直接对应视频
📄 Zheng et al., SGLang: Efficient Execution of Structured Language Model Programs (NeurIPS 2024, arXiv:2312.07104);vLLM 文档 Automatic Prefix Caching (设计文档 docs/design/prefix_caching.md);源码 python/sglang/srt/mem_cache/radix_cache.py
下一课 → 42 · 投机解码:Draft 模型、EAGLE、MTP
42 · Speculative Decoding:Draft 模型、EAGLE、MTP
⏱ 10 分钟 | 前置:41 · Prefix Caching 与 RadixAttention | 难度:★★☆
一句话
Decode 是 memory-bound 的:读一遍全部权重只产出 1 个 token,太亏;Speculative Decoding(投机解码) 让一个便宜的 draft 先猜 k 个 token,再让大模型(target )用一次 forward 并行 verify 这 k 个,配合 rejection sampling 保证输出分布与大模型逐个采样完全一致 ——用多余的算力换更少的「读权重次数」。
为什么要关心
第 36 课说过:batch 小时 decode 是 memory-bound (瓶颈是从 HBM 显存搬权重的带宽,而不是算力),Tensor Core 大量闲着。Speculative decoding 就是把这部分闲置算力用起来。它是目前唯一在不改变输出质量的前提下直接降低单用户 TPOT (Time Per Output Token,每个输出 token 的平均耗时,第 47 课)的主流手段,vLLM、SGLang、TensorRT-LLM 都内置,DeepSeek-V3 的 MTP、EAGLE-3 是线上常见配置。
核心直觉
Draft-Verify:先猜后验
普通 decode: [target] → t1 → [target] → t2 → [target] → t3 (3 次读权重)
投机解码: [draft] 猜 d1 d2 d3 d4 (很便宜)
[target] 一次 forward 同时算 4+1 个位置的分布
d1 ✔ d2 ✔ d3 ✘ → 用修正分布重采 t3'
本轮产出:d1 d2 t3' (1 次读权重,3 个 token)
关键点:target verify k 个 token 的代价 ≈ 一次「长度为 k+1 的小 prefill」,在 memory-bound 区间几乎和 decode 1 个 token 一样贵。所以只要猜中率够高,就是净赚。
🧩 难点拆解:为什么验证 k 个 token ≈ 一次 forward 的代价
直觉 :decode 一步的时间几乎全花在「把全部权重从 HBM 读进芯片」上,真正的乘加只占零头。权重读一次,拿来算 1 个位置还是 5 个位置,读权重的时间一样;多出来的只是很便宜的计算。类比:数据库全表扫描时顺手多检查几个条件——IO 成本不变,CPU 多做一点。
小例子 (Llama-3-8B,BF16,H100:3.35 TB/s、989 TFLOPS):
读权重:16 GB ÷ 3.35 TB/s ≈ 4.8 ms——不管算几个位置都要付;
算 1 个位置:2 × 8B FLOPs ÷ 989 TFLOPS ≈ 0.016 ms;
算 5 个位置(γ=4 个 draft + 1):≈ 0.08 ms。
所以 verify 5 个 token ≈ 4.8 + 0.08 ≈ 4.9 ms,普通 decode 1 个 ≈ 4.8 ms,几乎一样。(真实系统还有 attention 读 KV、kernel launch 等开销,但量级结论不变。)
一句话 :memory-bound 时读权重是固定成本,多验证几个位置几乎白送;batch 很大、已经 compute-bound 时这笔「白送」就没了(见下文「何时有用」)。
Acceptance rate 的直觉
两个符号:α(acceptance rate)= 每个 draft token 被 target 接受的概率(逐个独立近似,常见 0.6–0.9);γ = 每轮 draft 猜几个 token(即上文的 k,常见 3–5)。每轮 verify 期望产出的 token 数是:
E = \frac{1 - α^{γ+1}}{1 - α} = 1 + α + α^2 + … + α^γ
它想表达的是:拆成右边的求和更好懂。
第 1 项 1:无论如何本轮至少产出 1 个 token(被拒时 target 重采的那个,或全接受时白送的 bonus token);
第 2 项 α:第 1 个 draft 被接受的概率,接受就多 1 个;
第 3 项 α²:前 2 个 draft 都被接受的概率——一旦某个位置被拒,后面全作废,所以必须「连续命中」;
依此类推,直到 α^γ(全部命中)。
代入 α=0.8, γ=4:1 + 0.8 + 0.64 + 0.512 + 0.41 ≈ 3.36;α=0.6 时只有约 2.3。可以看到越往后的项越小,再多猜几个收益递减——这就是为什么 α 比 γ 更重要 ,各种方法的竞争本质上都是在提高 α 且降低 draft 成本。
Rejection sampling 为什么不改变分布
设 target 分布是 p(x),draft 分布是 q(x),draft 采到了 x:
以概率 min(1, p(x)/q(x)) 接受 ;
若拒绝,从 norm(max(0, p − q))(逐 token 取 p−q 的正数部分,再归一化成概率)里重新采样 一个 token,本轮结束。
可以证明最终每个位置的 token 严格服从 p(Leviathan et al. 2022 / Chen et al. 2023 的核心定理)。greedy(temperature=0)时退化为「draft token == target argmax 就接受」。
🧩 难点拆解:rejection sampling 保持分布(3 个 token 的词表)
直觉 :draft「过度自信」的 token(q > p)会被按比例砍掉一部分概率;砍下来的这些概率质量,正好被「重采」分给 draft「欠自信」的 token(p > q)。多退少补,最后账面与 p 分毫不差。
小例子 :词表只有 {A, B, C}。
A
B
C
target p
0.5
0.3
0.2
draft q
0.2
0.6
0.2
接受概率 min(1, p/q)
1
0.5
1
「draft 采到且被接受」q × 接受率
0.2
0.3
0.2
被接受的总概率 = 0.2+0.3+0.2 = 0.7,所以被拒的概率 = 0.3(全来自 B 被砍掉的那一半);
残差 max(0, p−q) = (0.3, 0, 0),归一化后 = (1, 0, 0),即被拒时一定重采到 A;
最终:A = 0.2(接受)+ 0.3 × 1(重采)= 0.5 ;B = 0.3 ;C = 0.2 ——正好等于 p。
一句话 :接受部分给出 min(p, q),重采部分补上 p − min(p, q),两者相加恰好是 p,所以投机解码是无损 加速。
Draft 从哪来:五条路线
路线
Draft 是什么
优点
代价
Draft model
同词表小模型(如 Llama-3.2-1B 给 70B 当 draft)
通用
要多跑一个模型、多一份 KV Cache;小模型 α 有限
Medusa
target 顶层 hidden 上多挂几个 decoding head(各是一个小 MLP + 输出层),第 i 个 head 直接猜 +i 位置;用 tree attention(见下文 EAGLE 部分)一次验证多条候选
无需独立模型
各 head 互相独立,越远越不准;论文报告 2.2x(Medusa-1)/2.3–3.6x(Medusa-2)
EAGLE 系列
一个 1 层左右的小 Transformer,吃 target 的 hidden feature 自回归地往前推
α 高、draft 极便宜
需要针对每个 target 训练 draft head
N-gram / Prompt Lookup
在 prompt(和已生成文本)里找与最近 n 个 token 匹配的片段,直接抄后续
零训练、零模型
只在「输出大量复读输入」时有效:RAG、代码编辑、摘要
MTP
模型训练时自带的 Multi-Token Prediction 模块(第 25 课:训练时额外预测后面第 2、3… 个 token 的小模块)
与主模型一起训,α 很高
只有带 MTP 的模型能用(DeepSeek-V3/R1、Qwen3-Next、GLM-4.5 等)
🧩 难点拆解:EAGLE 的 feature-level drafting 与 tree attention
直觉 ①:在 feature 层猜,而不是在 token 层猜。 target 最后一层 Transformer 输出的 hidden state(这里叫 feature ,一个 d_model 维向量,Llama-3-8B 是 4096 维)再乘 LM head 才变成词表上的 logits(未归一化分数)。token 是离散的、跳跃的;feature 是连续的、前后相邻位置变化更平滑,所以「根据前几个 feature 预测下一个 feature」比直接猜 token 容易。EAGLE 的 draft 是一个约 1 层的小 Transformer:输入 = target 已算好的 feature + 已采样 token 的 embedding,输出 = 下一个位置的 feature,再复用 target 自己的 LM head 变成 token。之所以要加「已采样 token」,是因为采样有随机性:同一个 feature 可能采出 "am" 也可能采出 "always",不告诉 draft 实际采了哪个,它就不知道该沿哪条路往下猜。
直觉 ②:tree attention,一次验证多条候选路径。 draft 不必只猜一条链,可以在每个位置留 top-2,形成一棵树:
"I"
/ \
"am" "have" ← 位置 1 的 2 个候选
/ \ / \
"a" "the" "a" "been" ← 位置 2 各 2 个候选,共 4 条路径
把 6 个节点拍平成一个长度 6 的序列交给 target,用一个特制的 attention mask :每个节点只能看见 prompt + 自己的祖先("the" 能看见 "am",看不见 "have")。于是 1 次 forward 同时得到 4 条路径上每个位置的 p,挑被接受得最长的那条。多花的只是 6 个位置的计算(memory-bound 下几乎免费),换来更高的期望接受长度。
一句话 :EAGLE 用 target 的 feature 做「半成品」来猜,α 更高;tree attention 用 mask 让一次 forward 验证一整棵候选树。
EAGLE 的演进 :
EAGLE-1 :发现「在 feature 层(LM head 之前的那层 hidden,论文称 second-to-top-layer feature)做自回归」比在 token 层更好预测,并把已采样 token 的 embedding 一起喂进去消除不确定性。
EAGLE-2 :draft tree 不再是静态形状,而是按 draft 的置信度动态扩展 (置信度 ≈ 接受率),把预算花在更可能被接受的分支上。
EAGLE-3 :放弃预测 feature,直接预测 token;融合低/中/高多层 feature ;用 training-time test (训练时模拟推理时 draft 自回归多步的输入分布)。论文报告最高约 6.5x 加速,比 EAGLE-2 约 1.4x。
MTP 作为 draft :DeepSeek-V3 技术报告称 MTP 模块预测的第二个 token 接受率 85%–90% ,带来约 1.8x TPS。
代码片段
# 投机采样的 verify(单序列、概念版)
def verify(draft_tokens, q_probs, p_probs):
# draft_tokens: [γ] draft 猜的 token
# q_probs: [γ, V] draft 在每个位置的分布
# p_probs: [γ+1, V] target 一次 forward 得到的 γ+1 个位置的分布
out = []
for i, x in enumerate(draft_tokens):
accept_p = min(1.0, p_probs[i, x] / q_probs[i, x])
if random() < accept_p:
out.append(x) # 接受,继续看下一个
else:
residual = (p_probs[i] - q_probs[i]).clamp(min=0)
out.append(sample(residual / residual.sum())) # 拒绝:从残差分布重采
return out # 后面的 draft 全作废
out.append(sample(p_probs[-1])) # 全接受:白送一个 bonus token
return out
最坏情况(第一个就拒)也产出 1 个 token,所以不会比普通 decode 少产出 ,只会多花 draft 与 verify 的计算。
全部接受时额外得到 bonus token,这就是公式里 γ+1 的来源。
真实实现是 batched、GPU 上向量化的:vLLM 的 vllm/v1/sample/rejection_sampler.py。
工程视角 / 真实世界
vLLM V1 :vllm/v1/spec_decode/ 下有 eagle.py(EAGLE/EAGLE-3,且各家 MTP 如 deepseek_mtp、qwen3_next_mtp 都复用这个 proposer 路径)、medusa.py、ngram_proposer.py、draft_model.py、suffix_decoding.py;通过 --speculative-config '{"method": "eagle3", "model": ..., "num_speculative_tokens": 3}' 开启。调度器把 spec token 当成普通 token 计入 token budget(第 45 课)。
SGLang :python/sglang/srt/speculative/,有 eagle_worker_v2.py、multi_layer_eagle_worker_v2.py、ngram_worker.py 等;DeepSeek 系模型常用 --speculative-algorithm EAGLE + MTP 权重。
何时有用 :低并发、在线交互(memory-bound)收益最大;高并发时 GPU 已接近 compute-bound,verify 的额外计算不再「免费」 ,加速比会下降甚至变负,所以很多系统按 batch size 动态调小 γ 或关闭。
α 依赖任务 :代码、格式化输出 α 高;开放式创作、高 temperature 时 α 低。报 benchmark 一定要说明数据集。
💬 能聊的要点
「投机解码是拿闲置算力换 memory 带宽:target 一次 forward 验证 k 个 token 的代价约等于 decode 一个 token,只要接受率高就净赚;rejection sampling 保证分布和原模型完全一致,是无损加速。」
「期望产出 (1−α^{γ+1})/(1−α),γ 的边际收益快速递减,所以提升 α、降低 draft 成本才是关键——EAGLE 用 target 的 hidden feature 做 draft、MTP 让 draft 和主模型一起训,本质都是在提高 α。」
「投机解码主要优化低 batch 的 TPOT;高并发时系统已经 compute-bound,verify 的浪费计算会吃掉吞吐,所以线上常按负载动态调整 draft 长度。」
常见误区
❌ 投机解码是近似,会降低质量 ✅ 标准 rejection sampling 下输出分布与 target 严格一致;只有某些「宽松接受」的变体(如 Medusa 的 typical acceptance:只要 draft token 在 target 看来「足够可能」就接受,不严格按 p/q)才是有损的
❌ 猜得越多(γ 越大)越快 ✅ 接受长度受 α^γ 限制很快饱和,γ 过大只增加 draft 和 verify 开销
❌ 投机解码能提高高并发下的总吞吐 ✅ 它主要降延迟;满载时可能降低吞吐
自测(心算/口答即可)
α=0.5、γ=3 时,每轮期望产出多少 token?答案 (1−0.5⁴)/(1−0.5) = 0.9375/0.5 ≈ 1.875 个。接受率低时收益很有限。
draft 在某 token 上给了 q=0.6,target 给 p=0.3,接受概率是多少?被拒后从哪里采样?答案 min(1, 0.3/0.6)=0.5;被拒后从 max(0, p−q) 归一化后的残差分布采样,该 token 自身在残差里概率为 0。
为什么做 RAG 问答时 n-gram / prompt lookup 往往有效,而写诗时几乎无效?答案 RAG 回答常大段引用检索文档里的原文,prompt 中能找到匹配的续写;写诗的输出几乎不复现输入,找不到可抄的片段,α≈0。
延伸
🎬 Karpathy:无直接对应视频
🎬 李沐:无直接对应视频
📄 Leviathan et al., Fast Inference from Transformers via Speculative Decoding (arXiv:2211.17192);Chen et al., Accelerating Large Language Model Decoding with Speculative Sampling (arXiv:2302.01318)
📄 Cai et al., Medusa (arXiv:2401.10774);Li et al., EAGLE (2401.15077) / EAGLE-2 (2406.16858) / EAGLE-3 (2503.01840)
📄 源码:vllm/v1/spec_decode/、vllm/v1/sample/rejection_sampler.py、python/sglang/srt/speculative/
下一课 → 43 · Quantization:INT8/INT4、AWQ/GPTQ、FP8、KV Cache 量化
43 · Quantization:INT8/INT4、AWQ/GPTQ、FP8、KV Cache 量化
⏱ 10 分钟 | 前置:42 · Speculative Decoding | 难度:★★☆
一句话
Quantization(量化) 用更少的 bit 存权重/激活/KV Cache:weight-only (如 W4A16)减少 decode 时要搬的字节,解决 memory-bound;W8A8 / FP8 让矩阵乘跑在低精度 Tensor Core 上,解决 compute-bound;KV Cache 量化 直接扩大能并发的 token 数。难点全在「outlier(离群值)」上,GPTQ、AWQ、SmoothQuant、block-scaled FP4 都是在和它斗。
为什么要关心
显存和带宽是推理的硬通货:70B 模型 BF16 要 140GB,一张 H100(80GB)放不下;换成 INT4 约 35GB,单卡能跑还剩空间给 KV Cache。线上几乎所有大模型部署都带量化(DeepSeek-V3 原生 FP8,gpt-oss 原生 MXFP4),看不懂 W4A16、FP8 block-wise、--kv-cache-dtype fp8 就没法做选型。
核心直觉
显存账本(先算账)
精度
每参数
70B 权重
说明
BF16
2 B
140 GB
基线
FP8 / INT8
1 B
70 GB
+ 少量 scale
INT4 / FP4
0.5 B
35 GB
+ scale:INT4 group-128 约 4.125 bit/参数(FP16 scale);MXFP4 约 4.25、NVFP4 约 4.5
KV Cache 同理。Llama-3-70B(80 层、8 个 KV head、head_dim 128)每 token 的 KV = 80 × 8 × 128 × 2(K,V) × 2B ≈ 320KB;32K 上下文单请求就是约 10GB。FP8 KV 直接减半,等于并发数翻倍。
量化的基本动作:scale + round
命名约定:W4A16 = Weight 4 bit、Activation 16 bit;W8A8 = 权重和激活都 8 bit。**Activation(激活)**指层与层之间流动的中间张量(每个 token 的 hidden 向量),它随输入变化,没法离线处理。
量化就是把连续的实数映射到少数几个整数「格子」上:q = round(w / s),用的时候 w ≈ q · s。scale s 是格子宽度;如果数据不以 0 为中心,再加一个 zero-point z (整数偏移):q = round(w/s) + z,w ≈ (q − z) · s。前者叫对称量化 ,后者叫非对称量化 。
🧩 难点拆解:scale / zero-point 与 outlier
直觉 :像把温度计刻度压缩成 16 格(INT4 只有 16 个值)——量程(最大值)决定每格多宽;量程被一个极端值撑大,普通值就全挤进同一格。
小例子 :4 个权重 w = [−0.8, −0.1, 0.3, 1.2] 量化到 INT4。
对称 (范围 [−8, 7]):s = max|w| / 7 = 1.2/7 ≈ 0.171;q = round(w/s) = [−5, −1, 2, 7];还原 q·s = [−0.857, −0.171, 0.343, 1.2],误差都在半格(约 0.086)以内。
非对称 (范围 [0, 15]):s = (1.2 − (−0.8)) / 15 ≈ 0.133,z = round(0.8/0.133) = 6;q = [0, 5, 8, 15];还原 (q−z)·s = [−0.8, −0.133, 0.267, 1.2]——数据不对称时 16 个格子都被用上,格子更细。
加一个 outlier :同组再放一个 10.0,对称 s = 10/7 ≈ 1.43,−0.1 和 0.3 都变成 q=0,信息全丢。
一句话 :scale 由组内最大值决定,一个 outlier 会把整组的格子撑粗——所以要缩小「共享一个 scale 的范围」,或者事先把 outlier 处理掉(下面的 AWQ/SmoothQuant)。
粒度(多少个元素共享一个 s)决定精度:
per-tensor :整个矩阵一个 s,最省但一个 outlier 就把所有值挤到少数格子里;
per-channel :每个输出通道(权重矩阵的一行)一个 s;
per-group / block :每 32/64/128 个元素一个 s(INT4 常用 group=128),精度好很多,代价是 scale 占空间、kernel 更复杂。
两大流派:谁在瓶颈就量化谁
Decode(小 batch):memory-bound → 少搬字节最重要
→ Weight-only:W4A16 / W8A16,权重读进来再在 kernel 里 dequant 成 BF16 算
Prefill / 大 batch:compute-bound → 算得快最重要
→ W8A8(INT8)/ FP8:激活也量化,直接用低精度 Tensor Core
H100 SXM dense:BF16 ≈ 989 TFLOPS,FP8 ≈ 1979 TFLOPS(翻倍)
W4A16 在大 batch 下可能反而变慢(dequant 开销 + 算力没省),这是选型时最常见的坑。
🧩 难点拆解:为什么 weight-only 只加速 decode、不加速 prefill
直觉 :weight-only 量化只让「搬权重」变快,乘法仍然用 BF16 算。谁的瓶颈是搬权重,谁就受益。
小例子 (Llama-3-8B,H100:3.35 TB/s、BF16 989 TFLOPS):
读权重
计算
瓶颈
W4A16 后
decode,batch=1
BF16 16 GB → 4.8 ms
2×8B×1 FLOPs → 0.016 ms
读权重
INT4 4 GB → 约 1.2 ms,接近 4x
prefill 2000 token
同样 4.8 ms
2×8B×2000 = 32 TFLOPs → 约 32 ms
计算
计算仍是 BF16 的 32 ms,几乎不变 ,还多了 dequant
一句话 :weight-only 省的是字节不是 FLOPs;要让 compute-bound 的 prefill/大 batch 变快,必须让矩阵乘本身跑在低精度 Tensor Core 上(W8A8 / FP8 / FP4)。
代表方法(都属于 PTQ:训练后量化,只需少量校准数据)
GPTQ (2022):逐层、逐列量化,利用二阶信息(Hessian 近似:衡量输出对每个权重有多敏感)把当前列的量化误差补偿到还没量化的列 上。能在约 4 GPU 小时内把 175B 模型压到 3–4 bit。
AWQ (Activation-aware Weight Quantization,MLSys 2024 best paper):观察到只有约 1% 的 salient 权重通道 (对应激活幅度大的通道)决定质量;不保留混合精度,而是在量化前把这些通道乘一个 scale 放大 (激活侧除回去,数学等价),让它们的相对量化误差变小。不需反向传播,泛化好。
SmoothQuant (2022):W8A8 的难点是激活 outlier 集中在少数 channel (大模型里可达上百倍),权重反而平滑。做法:每个 channel 用 s_j = max|X_j|^α / max|W_j|^{1−α}(常用 α=0.5)把激活除以 s、权重乘以 s——把量化难度从激活「迁移」到权重 ,然后两边都能 INT8。
🧩 难点拆解:AWQ 的「activation-aware」
直觉 :线性层输出 y = Σ_j x_j · w_j。权重 w_j 的量化误差 Δ 会被乘上对应的激活 x_j:x_j 很大的那几个 input channel(salient 通道),同样的 Δ 造成的输出误差也大。所以「该保护哪些权重」要看激活 ,而不是看权重本身大小。
小例子 :某 channel 激活常在 50 左右,其他 channel 约 1。量化误差同为 Δ=0.05 时,它对输出的贡献是 50×0.05 = 2.5,其他只有 0.05。AWQ 把这个 channel 的权重先乘 s=2 再量化:只要它不是组内最大值,组的 scale 基本不变,舍入误差仍约 0.05;再除回 2 后,对原权重的误差变成 0.025,输出误差降到约 1.25。激活侧 x_j / 2 被折进前一层的参数(如 LayerNorm 权重),推理时零额外开销。
一句话 :AWQ 用激活幅度找出「误差会被放大」的通道,靠等价缩放让它们在同样的格子里更精确,不需要混合精度。
🧩 难点拆解:SmoothQuant 的「难度迁移」
直觉 :X · W 里,激活 X 某一列(channel)特别大、权重对应那一行很小。把 X 的这一列除以 s、W 的这一行乘以 s,乘积不变,但两边的最大值被「拉平」了。像两个人抬担子,把重物往中间挪。
小例子 (α=0.5):两个 channel,激活最大值 [100, 1],权重最大值 [0.5, 0.5]。
s_1 = √(100/0.5) ≈ 14.1,s_2 = √(1/0.5) ≈ 1.41;
平滑后激活最大值 [100/14.1, 1/1.41] ≈ [7.07, 0.71],权重最大值 [0.5×14.1, 0.5×1.41] ≈ [7.07, 0.71];
激活两 channel 的差距从 100 倍降到 10 倍 ,权重从 1 倍升到 10 倍——两边都「中等难」,per-tensor/per-token INT8 都能接受。α=0.5 时每个 channel 两边的最大值恰好相等,都是 √(max|X_j| · max|W_j|)。
一句话 :激活难量化、权重好量化,就用一个数学等价的缩放把难度从激活匀给权重。
FP8:浮点也能 8 bit
E4M3 (4 位指数 3 位尾数,最大值 448):精度较高,推理权重/激活常用;E5M2 (最大 57344):范围大,传统 FP8 训练方案里用于梯度(DeepSeek-V3 训练则全部用 E4M3 + 细粒度 scale;训练对精度更敏感,所以要比推理切得更细,见第 28 课)。
浮点自带「对数刻度」,对 outlier 比 INT8 宽容,所以推理 时 FP8 常常只要 per-tensor 或 per-channel scale 就接近无损。DeepSeek-V3 用了更细的 block-wise FP8 (激活 1×128 tile、权重 128×128 block),权重就以 FP8 发布。
🧩 难点拆解:E4M3 vs E5M2
直觉 :8 bit = 1 位符号 + E 位指数 + M 位尾数。指数决定「能表示多大/多小」(量程),尾数决定「每个 2 的幂区间里切几格」(精度)。INT8 的格子等宽;浮点的格子随数值变大而变宽,相对误差大致恒定——所以对跨数量级的数据更宽容。
小例子 :看 [1, 2) 和 [2, 4) 两个区间里能表示哪些数。
格式
[1, 2) 内的值
[2, 4) 步长
3.3 会被存成
最大值
E4M3(3 位尾数 → 每区间 8 格)
1, 1.125, 1.25, …, 1.875
0.25
3.25
448
E5M2(2 位尾数 → 每区间 4 格)
1, 1.25, 1.5, 1.75
0.5
3.5
57344
一句话 :E4M3 精度高、量程小,适合数值分布较稳的权重/激活(配合 scale);E5M2 精度粗、量程大,适合动态范围大的梯度。
4-bit 浮点:MXFP4 与 NVFP4(Blackwell 原生支持)
MXFP4 (OCP Microscaling 标准):元素是 FP4(E2M1),每 32 个元素共享一个 E8M0 (纯 2 的幂)scale,约 4.25 bit/元素。gpt-oss 的 MoE 权重(占总参数 90%+)以 MXFP4 发布 ,使 gpt-oss-120b 能放进单张 80GB GPU、20b 能在 16GB 内跑。
NVFP4 (NVIDIA):每 16 个元素一个 FP8 E4M3 scale(再加一个 per-tensor FP32 scale),约 4.5 bit/元素;block 更小、scale 更精细,精度通常优于 MXFP4。
🧩 难点拆解:block scaling 为什么能让 4 bit 可用
直觉 :FP4(E2M1)的绝对值只有 8 个:0, 0.5, 1, 1.5, 2, 3, 4, 6。光靠它什么都表示不了,必须给每一小块配一个 scale,把这一块「缩放到 0–6 的量程里」。块越小,outlier 只祸害自己那一块。
小例子 (为便于演示用 4 个元素一块,真实 MXFP4 是 32 个):块 [0.1, 0.3, −0.2, 1.5]。
MXFP4 的 scale 只能是 2 的幂,取 0.25:值/0.25 = [0.4, 1.2, −0.8, 6] → 就近取 FP4 → [0.5, 1, −1, 6];
还原 ×0.25 = [0.125, 0.25, −0.25, 1.5],误差可接受;
若整个 tensor 共用一个 scale,而别处有个 outlier 96,scale 得是 16:0.1/16 等全部变成 0,这一块彻底丢失。
NVFP4 的 scale 是 FP8,可以取 1.5/6 = 0.25、0.3 这类任意值而不只是 2 的幂,块又只有 16 个元素,所以拟合更贴。
一句话 :4 bit 本身只有 16 个码字,精度几乎全靠细粒度的 block scale 撑着;block 大小和 scale 格式就是 MXFP4 与 NVFP4 的核心区别。
KV Cache 量化
KV 的精度需求比权重更敏感(误差会沿注意力累积)。主流是 FP8 KV (vLLM --kv-cache-dtype fp8,SGLang 同名参数),通常配合 per-tensor 或 per-head scale;更激进的 INT4/FP4 KV 也在出现(SGLang 有 nvfp4_kv_cache.py)。收益是可并发 token 数翻倍 ,在长上下文、高并发下往往比权重量化更划算。
代码片段
# per-group 对称 INT4 量化(权重),group_size=128
def quantize_w4(w, group=128): # w: [out, in],BF16
wg = w.reshape(w.shape[0], -1, group) # [out, in/128, 128]
s = wg.abs().amax(-1, keepdim=True) / 7 # INT4 对称范围 [-8, 7],每组一个 scale
q = (wg / s).round().clamp(-8, 7).to(torch.int8) # 实际会把两个 int4 打包进一个 byte
return q, s # 存 q(0.5B/参数)+ s(每 128 个参数一个)
def w4a16_linear(x, q, s): # decode 时:读 4bit 权重,片上反量化
w = (q.float() * s).reshape(q.shape[0], -1).to(x.dtype) # 真实 kernel 在寄存器里做
return x @ w.T # 仍用 BF16 Tensor Core 计算
# SmoothQuant:把激活 outlier 迁移到权重(X: [tokens, in],W: [out, in])
s = X.abs().amax(0) ** 0.5 / W.abs().amax(0) ** 0.5 # [in]:每个输入 channel 一个平滑因子(α=0.5)
X_s, W_s = X / s, W * s # X@W.T 结果不变,但 X 更平滑
amax/7 就是「用组内最大值决定格子大小」,组越小 outlier 影响越局部。
W4A16 省的是搬运 ,计算仍是 BF16;真实实现(Marlin、Machete 等 kernel)在寄存器里反量化,从不写回 HBM。
SmoothQuant 的两行变换是数学等价的,只改变了「谁更难量化」。
工程视角 / 真实世界
格式与工具 :GPTQ/AWQ checkpoint(HF 上大量 -AWQ/-GPTQ-Int4 模型);NVIDIA ModelOpt 产出 FP8/NVFP4;vLLM 生态的 llm-compressor 产出 compressed-tensors 格式;本地部署常用 llama.cpp 的 GGUF (Q4_K_M 等)。
框架代码 :vLLM vllm/model_executor/layers/quantization/;SGLang python/sglang/srt/layers/quantization/(可见 fp8.py、w8a8_int8.py、w8a8_fp8.py、mxfp4.py、awq/、gptq/、modelopt_quant.py)。
硬件决定选择 :A100 没有 FP8 Tensor Core → 多用 INT8/W4A16;H100/H200 → FP8 是默认甜点;B200/GB200 → FP4(NVFP4/MXFP4)原生支持。
质量验证 :别只看 perplexity(模型对测试文本的「困惑度」,越低越好),至少跑下游任务(MMLU、GSM8K、代码)和长上下文;推理模型的长链路对量化更敏感。
💬 能聊的要点
「量化要看瓶颈:decode 小 batch 是 memory-bound,W4A16 这类 weight-only 最有效;prefill 和大 batch 是 compute-bound,要 W8A8/FP8 才能用上低精度 Tensor Core,H100 上 FP8 算力是 BF16 的两倍。」
「大模型量化的核心敌人是 activation outlier:AWQ 放大 salient 通道保护权重,SmoothQuant 把激活的难度迁移给权重,FP4 用 16/32 元素的 block scale 把 outlier 的影响限制在局部。」
「KV Cache 量化常常比权重量化更赚:FP8 KV 让同样显存能放下两倍 token,直接提高并发和吞吐。」
常见误区
❌ INT4 一定比 FP8 快 ✅ W4A16 只省带宽,大 batch 时 compute-bound 反而可能不如 FP8 W8A8
❌ 量化后只要 perplexity 差不多就行 ✅ 数学推理、长上下文、代码等任务更敏感,要用下游评测
❌ MXFP4 和 NVFP4 只是名字不同 ✅ block 大小(32 vs 16)和 scale 格式(E8M0 vs E4M3)不同,精度与硬件支持都有差异
自测(心算/口答即可)
一个 32B 模型用 W4(group=128,FP16 scale)大约占多少显存?答案 32B × 0.5B = 16GB;scale 每 128 参数 2 字节,即每参数 2/128 ≈ 0.016 B,相对 0.5 B 约 +3%,共 32B × 0.016 ≈ 0.5GB,合计约 16.5GB(嵌入层/LM head 常保留高精度,实际略大)。
为什么 SmoothQuant 要「迁移」难度,而不是直接用 per-token 量化激活?答案 激活的 outlier 集中在固定的少数 channel(列),而 per-token 是按行分组,无法隔离列方向的 outlier;per-channel 量化激活又和 GEMM 的计算方式不兼容。缩放 channel 把它挪到权重(权重可以 per-channel 量化)。
Llama-3-70B 开 FP8 KV Cache,每 token KV 多少?答案 80 × 8 × 128 × 2 × 1B ≈ 160KB,是 BF16 的一半。
延伸
🎬 Karpathy:无直接对应视频
🎬 李沐:无直接对应视频
📄 Frantar et al., GPTQ (arXiv:2210.17323);Lin et al., AWQ (arXiv:2306.00978);Xiao et al., SmoothQuant (arXiv:2211.10438)
📄 NVIDIA 技术博客 Introducing NVFP4 for Efficient and Accurate Low-Precision Inference ;OpenAI gpt-oss-120b & gpt-oss-20b Model Card (arXiv:2508.10925)
下一课 → 44 · Distributed Inference:TP / PP / EP / DP-Attention / PD 分离
44 · Distributed Inference:TP / PP / EP / DP-Attention / PD 分离
⏱ 10 分钟 | 前置:43 · Quantization | 难度:★★★
一句话
单卡放不下或跑不快时,要把模型「切」到多卡:TP 切每层的矩阵(每层 all-reduce),PP 按层切(跨节点),EP 把 MoE 的 expert 分到不同卡(all-to-all),DP-Attention 让注意力按请求切、避免 MLA 的 KV 被复制;再往上,PD 分离 把 prefill 和 decode 放进不同的 GPU 池,中间靠高速网络传 KV Cache。
为什么要关心
DeepSeek-V3/R1 有 671B 参数,FP8 权重就约 700GB,一台 8×H100(640GB)装不下;即使装得下,选错并行方式吞吐可能差数倍。「TP 还是 EP」「要不要 PD 分离」是 AI Infra 面试和实际部署里最高频的问题,这一课把它们放在一张图里。
核心直觉
TP(Tensor Parallelism):每层都要开会
Megatron 式切法(第 30 课):Attention 按 head 切、FFN 第一个矩阵按列切、第二个按行切,这样每个 block 只在末尾做 1 次 all-reduce ,一层(Attention + FFN)共 2 次 。
GPU0 GPU1 (TP=2)
[head 0-15] [head 16-31] → all-reduce(求和)
[FFN 列 半] [FFN 列 另半] → all-reduce(求和)
两个通信原语先说清:all-reduce = 每张卡各有一份同形状的张量,通信后每张卡都拿到「所有卡之和」;all-to-all = 每张卡给每张卡各发一份不同 的数据(下文 EP 用)。NVLink 是机内 GPU 直连(H100 单向约 450 GB/s),RDMA (InfiniBand/RoCE)是跨机网卡直读直写显存(单卡常见 400 Gb/s ≈ 50 GB/s)。
🧩 难点拆解:为什么 TP 每个 block 恰好一次 all-reduce
直觉 :FFN 是 y = act(x·A)·B。A 按列 切:每张卡算出中间结果 h 的一部分列,完整且互不依赖,激活函数逐元素做,不用通信。B 按行 切:每张卡用自己那部分 h 乘自己那几行 B,得到的是完整形状的 y,但只是部分和 ——必须把各卡的部分和加起来,这就是那次 all-reduce。
小例子 (TP=2,省略激活函数):x = [1, 1],A = [[1,2],[3,4]],B = [[1,1],[2,0]]。
GPU0 持有 A 的第 0 列,算 h0 = 1·1 + 1·3 = 4;GPU1 持有第 1 列,算 h1 = 1·2 + 1·4 = 6(无通信);
GPU0 持有 B 的第 0 行,算 4 × [1,1] = [4, 4];GPU1 持有第 1 行,算 6 × [2,0] = [12, 0];
all-reduce 求和:[16, 4],两卡都拿到完整的 y(与单卡算 x·A·B 结果相同)。
Attention 同理:按 head 切相当于「列切」QKV、「行切」输出投影 O,末尾一次 all-reduce。
一句话 :列切 + 行切的配对让中间不用通信,只在每个 block 结尾把部分和加一次——每层(Attention + FFN)2 次。
每次 all-reduce 的数据量 = 本步 token 数 × hidden × 2B。例:Llama-3-70B(hidden 8192)decode 一步 batch=64,每次只有 64 × 8192 × 2B = 1 MiB,但 80 层 × 2 = 160 次 同步,每次都要等最慢的卡、付固定延迟(量级约十几微秒),累计就是毫秒级。数据量小但延迟敏感 ,所以 TP 基本只放在 NVLink 域内 (单机 8 卡,或 GB200 NVL72 的 72 卡)。TP 能降低单请求延迟,但 KV Cache 也按 head 被切开——前提是 KV head 数 ≥ TP(GQA 只有 8 个 KV head 时 TP>8 就要复制 KV)。
PP(Pipeline Parallelism):按层切,跨机友好
每个 stage 放连续若干层,stage 之间只传一次 activation(点对点),通信量小,适合跨节点。代价是单请求要依次流过所有 stage,延迟不降反增 ,需要多个 micro-batch 同时在流水线里才能填满(vLLM V1 为此有 step_with_batch_queue)。推理中 PP 主要用于「一台机器放不下」的场景。
EP(Expert Parallelism):MoE 的主战场
MoE 每个 token 只激活少数 expert(DeepSeek-V3:256 个 routed expert 选 8 个 + 1 个 shared expert)。EP 把 expert 分到不同 GPU,每层 MoE 前后各一次 all-to-all :
dispatch(all-to-all):token 按路由结果发往持有对应 expert 的 GPU
expert 计算(grouped GEMM,如 DeepGEMM)
combine(all-to-all):结果发回 token 原来的 GPU,加权求和
🧩 难点拆解:EP 的 dispatch / combine
直觉 :像 MapReduce 的 shuffle——每张卡上的 token 按「要去哪个 expert」分桶,发到持有该 expert 的卡;算完再原路 shuffle 回来。类比不成立之处:shuffle 是一次性的批处理,这里每层 MoE 都要两次 、同步进行、预算只有几十到几百微秒;热门 expert 就是「数据倾斜」,会拖慢整层。
小例子 :4 张卡、8 个 expert(每卡 2 个:GPU0 持 E0/E1,GPU1 持 E2/E3,…,GPU3 持 E6/E7),top-2 路由。GPU0 上的 token t 被 Router 选中 E3(权重 0.7)和 E6(权重 0.3):
dispatch :把 t 的 hidden 向量复制两份,一份发 GPU1、一份发 GPU3(其他卡上的 token 同时也在互相发——这就是 all-to-all);
expert 计算 :GPU1 用 E3 算出 o3,GPU3 用 E6 算出 o6,各自把发来的 token 拼成一个大矩阵做 grouped GEMM;
combine :o3、o6 发回 GPU0,GPU0 计算 0.7·o3 + 0.3·o6,t 这一层的 MoE 输出就完成了。
一句话 :dispatch = 按路由把 token 送到 expert 所在的卡,combine = 把结果送回原卡加权求和;一层两次 all-to-all,每个 token 要被复制 k 份在网络上跑。
DeepEP (DeepSeek 开源)提供两套 kernel:normal (高吞吐,NVLink + RDMA 转发,用于 prefill)与 low-latency (纯 RDMA、兼容 CUDA Graph,用于 decode)。EP 的难题是负载不均 :热门 expert 所在的卡成为瓶颈,所以有 EPLB (复制热门 expert 成冗余副本并重新摆放)。
DP-Attention:为 MLA 量身定做
MLA(第 20 课)把每个 token 的 K、V 压成一个所有 head 共享的低维 latent 向量(512+64 维)来存,没有「多个 KV head」可切。若对 Attention 用 TP=8,每张卡都得存完整的 latent KV → KV Cache 被复制 8 份。SGLang 的 DP-Attention :Attention 部分按请求 切(每卡处理不同请求、只存自己请求的 KV),MoE 部分走 EP,两者之间做 gather/scatter。SGLang v0.4 博客报告 DeepSeek 解码吞吐 1.9x (相对 v0.3)。
🧩 难点拆解:DP-Attention 省下了什么
直觉 :TP 切的是「每个请求内部的 head」,MLA 只有一份 latent,切不动,只能每卡各存一份——像数据库每个节点都存全量副本。DP-Attention 改成按请求分片 (sharding):每个请求只落在一张卡上,KV 只存一份。
小例子 (DeepSeek-V3,每 token KV 约 69 KB;每卡留 40 GB 给 KV,8 卡):
TP=8 :8 张卡存的是同一批 token 的同一份 KV,系统总容量 = 单卡容量 = 40 GB ÷ 69 KB ≈ 60.8 万 token;
DP=8 :每卡存不同请求,总容量 = 8 × 60.8 万 ≈ 486 万 token,可同时跑的请求数约多 8 倍;
代价:attention 算完后,各卡的 token 要汇合进同一个 EP 的 MoE 层(gather/scatter 或直接走 dispatch),且各卡请求长短不一时会互相等待(负载不均)。
一句话 :MLA 的 KV 切不开,就别切「请求内部」,改为切「请求之间」——KV 从复制 8 份变成只存 1 份。
PD Disaggregation:prefill 和 decode 分家
Prefill 是 compute-bound、decode 是 memory-bound(第 36 课),混在一起会互相干扰 :一个长 prompt 的 prefill 会让同 batch 所有 decode 请求的 TPOT 抖一下。分离后:
请求 → Router ─▶ Prefill 池(大 batch 算 prompt,产出 KV)
│ KV Cache 走 RDMA/NVLink 传输
▼
Decode 池(大量并发请求一起 decode)─▶ 流式输出
术语(第 47 课详讲):TTFT = 首 token 延迟,TPOT = 之后每个 token 的间隔,SLO = 对这些延迟的承诺(如 P99 TTFT < 1s),goodput = 满足 SLO 的请求吞吐。
🧩 难点拆解:PD 分离与 KV 传输
直觉 :像把「批量构建(build farm)」和「在线服务」拆成两个集群——prefill 是一次性的大计算,decode 是长时间、低强度的流式服务,放一起时互相抢资源;分开后各自按自己的瓶颈配硬件和并行方式。拆开的代价是中间产物(KV Cache)要搬过去。类比不成立之处:这里的「产物」每个请求都不同、体积很大,而且必须在几十毫秒内送达,否则 TTFT 直接变差。
小例子 :Llama-3-70B(KV 每 token 320 KiB),一个 4000 token 的 prompt:
KV 大小 = 4000 × 320 KiB ≈ 1.22 GiB;
走 400 Gb/s RDMA(约 50 GB/s)传输 ≈ 26 ms ;
同一 prompt 在 8×H100 上 prefill:2 × 70B × 4000 ≈ 560 TFLOPs,按 50% 利用率约 140 ms ;
传输约占 prefill 的 1/5,而且可以按层流水 (第 1 层 KV 算完就开始发,同时算第 2 层),大部分被掩盖。
一句话 :PD 分离用「多搬一次 KV」换来 prefill 和 decode 互不干扰、各自最优配置;只要 KV 传输能被 prefill 计算掩盖,就是划算的。
DistServe (OSDI'24):提出以 goodput (满足 TTFT/TPOT SLO 的请求率)为目标,分离后可服务 7.4x 请求或满足 12.6x 更严的 SLO。
Splitwise (微软,ISCA'24):两阶段可用不同硬件;同成本下 2.35x 吞吐 ,或 1.4x 吞吐且成本低 20% 。
Mooncake (Moonshot/Kimi,FAST'25 best paper):KVCache-centric ——用集群闲置的 CPU DRAM/SSD 组成分布式 KV 池,调度器以「KV 在哪」为中心做决策;模拟场景下吞吐最高提升 525%。其 Transfer Engine 已被 vLLM、SGLang 用作 KV 传输后端。
NIXL (NVIDIA Inference Xfer Library,Dynamo 的一部分):统一 GPU↔GPU/CPU/存储的点对点传输,底层 UCX/RDMA;vLLM 的 NixlConnector、SGLang 的 disaggregation/nixl/ 都用它。
代价:多一次 KV 传输(带宽要够)、两个池的配比要随流量调、系统复杂度大增。短 prompt、低并发时不值得。
大规模部署实例:DeepSeek 官方推理系统
DeepSeek 在 2025 年 2 月开源周公布的 V3/R1 线上架构:
Prefill :每个部署单元 4 节点 ,routed expert EP32 ,MLA/shared expert DP32 ;
Decode :每个单元 18 节点 ,routed expert EP144 ,MLA/shared expert DP144 ;
双 micro-batch 交替执行,用一个 batch 的计算掩盖另一个的 all-to-all 通信;
每台 H800 节点平均 prefill 输入约 73.7k tokens/s 、decode 输出约 14.8k tokens/s ;输入 token 中 56.3% 命中磁盘 KV 缓存。
SGLang 团队随后在 **96 张 H100(12 节点)**上用 PD 分离 + DeepEP + DP-Attention + EPLB + Two-Batch Overlap 复现,2000 token 输入下每节点 52.3k 输入 / 22.3k 输出 tokens/s 。
代码片段
# EP 下一层 MoE 的前向(概念版,每张卡上跑同样的代码)
def moe_layer_ep(x, router, local_experts, ep_group):
topk_idx, topk_w = router(x) # 每个 token 选 k 个 expert
# dispatch:按 expert 所在 rank 分桶,all-to-all 发出去
recv_x, recv_meta = all_to_all_dispatch(x, topk_idx, ep_group) # DeepEP 负责这一步
out = grouped_gemm(local_experts, recv_x, recv_meta) # 只算本卡持有的 expert
# combine:结果发回原 rank,按路由权重加权求和
y = all_to_all_combine(out, recv_meta, topk_w, ep_group)
return y + shared_expert(x) # shared expert 本地算,可与通信重叠
一层两次 all-to-all,数据量 ≈ token 数 × k × hidden,这就是 EP 对 RDMA 带宽要求高的原因。
shared_expert(x) 不依赖通信结果,可以和 dispatch/combine overlap ;Two-Batch Overlap 则是让 batch A 算的时候 batch B 在通信。
PD 分离下一个请求的旅程(概念版;对应 SGLang 的 --disaggregation-mode prefill|decode、vLLM 的 --kv-transfer-config + KV connector):
async def handle(req):
P, D = router.pick_prefill(), router.pick_decode() # 1. 路由层同时选一对实例
dst = await D.reserve_kv(len(req.prompt_ids)) # 2. D 先预留好 KV 显存(不够就排队)
first_tok, kv = await P.prefill(req.prompt_ids) # 3. P 做 prefill:得到第 1 个 token + 所有层的 KV
await transfer(kv, src=P, dst=(D, dst)) # 4. RDMA 直接写进 D 的显存(NIXL / Mooncake)
P.free_kv(req) # P 立刻释放显存,去接下一个 prompt
async for tok in D.decode(req, first_tok): # 5. D 把它并进自己的 continuous batch
yield tok # 从第 2 个 token 开始流式返回
第 2 步「先预留再计算」是关键:如果 P 算完才发现 D 没地方放,这份 KV 只能干等或重算。
第 4 步实际是逐层 发送:第 i 层的 KV 算完就开始传,与第 i+1 层的计算重叠,所以传输大多被藏在 prefill 时间里(见上面的难点拆解)。
对比不分离:同一张卡上 prefill 和 decode 抢同一批 SM,一个长 prompt 会让所有正在 decode 的请求卡顿(TPOT 抖动);分离后两边各自按自己的瓶颈(算力 vs 带宽)配置机器和并行策略。
工程视角 / 真实世界
常见组合 :稠密模型 70B → 单机 TP=8(或 TP=4 × DP=2);DeepSeek 类 MoE → DP-Attention + EP 跨多机 + PD 分离;GB200 NVL72 让大 EP 域可以完全走 NVLink。
vLLM :--tensor-parallel-size、--pipeline-parallel-size、--data-parallel-size、--enable-expert-parallel;KV 传输抽象在 vllm/distributed/kv_transfer/kv_connector/v1/(nixl/、mooncake/、lmcache_connector.py 等)。
SGLang :--tp、--dp、--enable-dp-attention、--ep-size/DeepEP 相关参数;PD 分离代码在 python/sglang/srt/disaggregation/(prefill.py、decode.py、mooncake/、nixl/)。
编排层 :NVIDIA Dynamo 、llm-d 等在引擎之上做 PD 路由、KV-aware 路由和弹性扩缩。
💬 能聊的要点
「TP 每层两次 all-reduce、延迟敏感,只适合 NVLink 域内;PP 通信少适合跨机但不降延迟;MoE 的主力是 EP,一层两次 all-to-all,DeepEP 针对 prefill/decode 分别给了高吞吐和低延迟两套 kernel。」
「MLA 只有一个 latent KV,对 Attention 做 TP 会把 KV 复制 TP 份,所以 DeepSeek 类模型用 DP-Attention + EP:注意力按请求切,MoE 按 expert 切。」
「PD 分离解决的是 prefill 对 decode 的干扰,优化目标从 throughput 变成 goodput;代价是 KV 传输和两池配比,DeepSeek 线上就是 prefill 4 节点 EP32、decode 18 节点 EP144 的分离架构。」
常见误区
❌ TP 越大越快 ✅ 通信次数固定而每卡计算变少,超过 NVLink 域或 KV head 数后收益迅速变差
❌ PD 分离总是更好 ✅ 短 prompt、低并发或网络差时,KV 传输开销和运维复杂度可能得不偿失;chunked prefill 也能缓解干扰
❌ EP 只是「把 expert 分开放」 ✅ 真正的难点是 all-to-all 通信的 overlap 和 expert 负载均衡(EPLB、冗余 expert)
自测(心算/口答即可)
Llama-3-70B(80 层)TP=8 decode 一步要做多少次 all-reduce?答案 每层 2 次,共 160 次(不计 embedding/LM head 的少量通信)。这就是 TP 必须走低延迟 NVLink 的原因。
为什么 DeepSeek 的 decode 单元(18 节点)比 prefill 单元(4 节点)大得多?答案 decode 是 memory-bound,需要极大的并发 batch 才能把 expert GEMM 做「胖」;把 expert 摊到 144 张卡上后每卡只放约 2 个 routed expert,更多显存留给 KV Cache 和更大的 batch。prefill 本身 token 多、已经 compute-bound,小单元就够。
PD 分离后,TTFT 和 TPOT 分别主要由哪个池决定?答案 TTFT 由 prefill 池(排队 + prefill 计算)加 KV 传输决定;TPOT 由 decode 池的 batch 大小和负载决定,且不再被新请求的 prefill 打断。
延伸
🎬 Karpathy:无直接对应视频
🎬 李沐:无直接对应视频(Megatron-LM 相关可参考论文精读系列)
📄 Zhong et al., DistServe (arXiv:2401.09670);Patel et al., Splitwise (arXiv:2311.18677);Qin et al., Mooncake (arXiv:2407.00079)
📄 DeepSeek Day 6: DeepSeek-V3/R1 Inference System Overview (github.com/deepseek-ai/open-infra-index);LMSYS 博客 Deploying DeepSeek with PD Disaggregation and Large-scale Expert Parallelism on 96 H100 GPUs (2025-05);github.com/deepseek-ai/DeepEP
下一课 → 45 · vLLM 架构走读:一个请求的一生
45 · vLLM 架构走读:一个请求的一生
⏱ 10 分钟 | 前置:44 · Distributed Inference | 难度:★★★
一句话
vLLM V1 把系统拆成三层进程:API Server(AsyncLLM:tokenize/detokenize/流式输出) ⇄ ZeroMQ ⇄ EngineCore(busy loop:Scheduler + KVCacheManager) ⇄ Executor → Worker → GPUModelRunner(每张 GPU 一个进程,跑模型) ;Scheduler 不区分 prefill/decode,每步只输出一个 {request_id: num_tokens} 字典,在统一的 token budget 下把 chunked prefill、prefix caching、spec decode 都表达成「让 num_computed_tokens 追上 num_tokens」。
为什么要关心
前面十课讲的 PagedAttention、continuous batching、prefix caching、spec decode,在 vLLM 里都对应具体的类和文件。能从源码层面讲清「一个请求从 HTTP 进来到 token 流出去经过了哪些组件」,是 AI Infra 岗位读代码、定位性能问题、写 PR 的起点。
核心直觉
为什么是多进程
V0 里 tokenize、detokenize、调度、Python 对象管理都和 GPU 执行挤在一个进程,Python 的 CPU 开销让 GPU 频繁空等(小模型尤其严重)。V1(2025 年 1 月 alpha,官方称吞吐最高 1.7x )的思路:把 CPU 杂活和 GPU 主循环拆进不同进程 ,用 ZeroMQ(一个无 broker 的轻量消息库,用法像 socket)通信、msgpack(二进制版 JSON)序列化,让它们真正并行。之所以必须是进程 而不是线程:Python 有 GIL(全局解释器锁),同一进程里的多个 Python 线程同一时刻只有一个在跑。
┌──────────── Process 1: API Server ─────────────┐
│ FastAPI (vllm/entrypoints/openai/api_server.py) │
│ AsyncLLM (vllm/v1/engine/async_llm.py) │
│ InputProcessor: chat template + tokenize │
│ OutputProcessor: detokenize + 流式 + stop 检查 │
└──────────────┬──────────────────────────────────┘
│ ZeroMQ(AsyncMPClient, core_client.py)
┌──────────────▼── Process 2: EngineCore ─────────┐
│ EngineCoreProc.run_busy_loop (v1/engine/core.py)│
│ Scheduler (v1/core/sched/scheduler.py) │
│ KVCacheManager (v1/core/kv_cache_manager.py) │
│ └ BlockPool:空闲块链表 + prefix hash 表 │
│ Executor (v1/executor/multiproc_executor.py) │
└──────────────┬──────────────────────────────────┘
│ 广播 SchedulerOutput(共享内存消息队列)
┌──────────────▼── Process 3..N: Worker × GPU ────┐
│ Worker (v1/worker/gpu_worker.py) │
│ GPUModelRunner (v1/worker/gpu_model_runner.py)│
│ persistent batch / block table / attn meta │
│ model forward(torch.compile + CUDA Graph) │
│ Sampler / RejectionSampler │
└─────────────────────────────────────────────────┘
TP=4 时有 4 个 Worker 进程,EngineCore 只管调度,Worker 之间用 NCCL(NVIDIA 的 GPU 集合通信库)做 all-reduce。
🧩 难点拆解:为什么拆成三类进程
直觉 :类比一个典型的后端服务——API Server 是无状态的 Web 层(解析请求、序列化响应、推流),EngineCore 是单线程的调度中心(像 Redis 的 event loop,所有状态变更都在一个循环里,不用加锁),Worker 是执行节点(每张 GPU 一个,只接指令跑模型)。三者用消息队列解耦,谁也不阻塞谁。类比不成立之处:Worker 之间不是独立的——TP 下它们每层都要 all-reduce,必须步调一致地执行同一个 SchedulerOutput。
小例子 (假设数字,只为说明比例):小模型 decode 一步 GPU 只要 6 ms,而本步的 CPU 杂活(HTTP、detokenize 256 个请求、流式推送、调度)要 2 ms。
同一进程串行 :一步 = 6 + 2 = 8 ms,GPU 利用率只有 6/8 = 75% ;
拆进程并行 :API Server 在做 detokenize 时,EngineCore 已在调度下一步、Worker 在跑 GPU——一步 ≈ 6 ms,吞吐约 1.33x ;
模型越小、GPU 越快,CPU 那 2 ms 占比越高,拆进程的收益越大。
一句话 :把「CPU 杂活」从「GPU 关键路径」上挪开,让 GPU 的每一步不再等 Python。
统一的 Scheduler:没有「prefill 阶段」
源码注释原话大意:调度器里没有 decoding phase 也没有 prefill phase ,每个请求只有 num_computed_tokens 和 num_tokens_with_spec,每步尽量给请求分配 token 让前者追上后者。于是:
新请求 :需要 2000 个 token,但本步预算只剩 512 → 先算 512(这就是 chunked prefill ,V1 默认开启);
decode 请求 :每步需要 1 个 token(开 spec decode 时是 1+k 个);
prefix cache 命中 :num_computed_tokens 直接从命中的块数起步,跳过这部分计算。
每步的总 token 数受 max_num_batched_tokens(token budget)限制,先排 running 队列(优先保证 decode 不断流),再从 waiting 队列拉新请求 。显存不够分配新块时,**抢占(preempt)**最后进入的 running 请求(释放其 KV,之后重新计算)。
🧩 难点拆解:token budget 如何统一 prefill 和 decode
直觉 :把 GPU 的一步想象成一辆容量固定的货车(budget = 能装多少 token)。每个请求都只是「还欠多少 token 没算」:decode 请求每步欠 1 个,新请求欠整个 prompt。调度器按顺序往车上装,装不下的下一趟再说——prefill 被切块、prefix 命中少欠一些,都不需要特殊逻辑。
小例子 (budget = 2048):running 里有 3 个 decode 请求和 1 个还剩 3000 token 没 prefill 完的请求 R;waiting 里有新请求 N(1500 token,前 1024 个命中 prefix cache)。
步
3 个 decode
R(剩 3000)
N(欠 1500−1024=476)
剩余 budget
第 1 步
各 1 → 用 3
给 2045(剩 955)
没位置,继续等
0
第 2 步
用 3
给 955,prefill 完成
进入 running,给 476
614
第 3 步
用 3
开始 decode,用 1
开始 decode,用 1
2043
R 的 prefill 被切成 2045 + 955 两块(chunked prefill),N 因 prefix 命中只算了 476 个——同一套「欠多少给多少」的逻辑。真实调度器还有 max_num_seqs(最大并发请求数)、long_prefill_token_threshold 等上限,这里省略。
一句话 :V1 的调度就是「在 token budget 内,尽量让每个请求的 num_computed_tokens 追上它该有的 token 数」,prefill/decode/chunked/prefix 都是这个规则的特例。
KVCacheManager 与 prefix caching
KV 按固定大小的 block(默认 16 token)分配。每个满块算一个链式哈希 (父块哈希 + 本块 token),存进 BlockPool 的哈希表;块被释放时不立刻清空,而是放进 LRU 顺序的空闲队列,被别的请求命中就「复活」。V1 的实现号称 cache 命中率 0% 时吞吐损失也 <1% ,所以 prefix caching 默认开启。
GPUModelRunner:把 CPU 开销压到最低
Persistent batch :GPU 上的输入张量(token id、位置、block table 等)常驻,每步只按 diff 增删行,而不是每步重建(gpu_input_batch.py、block_table.py);
torch.compile :PyTorch 的编译器,把 Python 模型代码抓成计算图再生成融合 kernel;vllm/compilation/ 里的自定义 backend 做算子融合(如把 norm + 量化合成一个 kernel);
Piecewise CUDA Graph :CUDA Graph 把一整串 kernel launch「录制」下来、之后一次调用整体「回放」,省掉每个 kernel 各自的 CPU 发射开销(每次约几微秒,一步有几百上千个 kernel);但录制时形状是固定的,所以 attention 这种形状多变的部分留在图外,其余部分按若干 batch size 预先 capture,decode 时直接 replay。
代码片段
先从用户视角 看 vLLM 怎么用(三种最常见的入口,背后都是同一个 EngineCore):
# ① 离线批量推理:Python 里直接用引擎,适合跑评测、造数据
from vllm import LLM, SamplingParams
llm = LLM(model="Qwen/Qwen3-8B", max_model_len=8192)
outs = llm.generate(["解释一下 KV Cache"], SamplingParams(temperature=0.7, max_tokens=256))
print(outs[0].outputs[0].text)
# ② 在线服务:起一个 OpenAI 兼容的 HTTP server(生产最常用)
vllm serve Qwen/Qwen3-8B --tensor-parallel-size 2 --max-model-len 32768 \
--gpu-memory-utilization 0.9 --max-num-seqs 256 --max-num-batched-tokens 8192
# ③ 客户端:任何 OpenAI SDK 都能直接调,stream=True 走 SSE 流式返回
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
stream = client.chat.completions.create(model="Qwen/Qwen3-8B", stream=True,
messages=[{"role": "user", "content": "什么是 PagedAttention?"}])
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")
启动参数几乎一一对应前面的课:--tensor-parallel-size → TP(第 44 课);--max-model-len、--gpu-memory-utilization → KV Cache 预算(第 37 课);--max-num-seqs、--max-num-batched-tokens → 调度器的并发数和 token budget(第 39 课);prefix caching 在 V1 默认开启(第 41 课)。
SGLang 的对应命令是 python -m sglang.launch_server --model-path Qwen/Qwen3-8B --tp-size 2(默认端口 30000),同样提供 OpenAI 兼容接口,客户端代码不用改。
再看引擎内部:
# EngineCore 主循环(简化自 vllm/v1/engine/core.py)
class EngineCore:
def step(self):
if not self.scheduler.has_requests():
return {}
sched_out = self.scheduler.schedule() # {req_id: num_tokens} + 新分配的 block
future = self.model_executor.execute_model(sched_out, non_block=True)
bitmask = self.scheduler.get_grammar_bitmask(sched_out) # 结构化输出掩码,与 GPU 并行算
model_out = future.result() # 等 Worker 返回
if model_out is None:
model_out = self.model_executor.sample_tokens(bitmask) # 带 grammar 掩码再采样
return self.scheduler.update_from_output(sched_out, model_out) # 追加 token、判断结束、释放 KV
# Scheduler.schedule() 的骨架(简化)
budget = max_num_batched_tokens
for req in running: # 1. 先照顾正在跑的请求
n = min(req.num_tokens_with_spec - req.num_computed_tokens, budget)
if not kv_cache_manager.allocate_slots(req, n): # 显存不足 → 抢占队尾请求
preempt(running.pop())
budget -= n
while waiting and budget > 0: # 2. 再接纳新请求
req = waiting.peek()
hit = kv_cache_manager.get_computed_blocks(req) # prefix cache 命中的块
n = min(req.num_tokens - hit.num_tokens, budget) # 超出预算就 chunked prefill
...
execute_model(..., non_block=True) + 并行计算 grammar bitmask:CPU 和 GPU 同时干活,这是 V1 里反复出现的模式。
update_from_output 之后的输出通过 ZMQ 回到 API Server,由 OutputProcessor 增量 detokenize、按 SSE(Server-Sent Events,HTTP 流式推送)推给客户端。
调度结果只是「每个请求这一步算几个 token」,所以 spec decode、chunked prefill 不需要特殊分支。
工程视角 / 真实世界
一个请求的一生 (POST /v1/chat/completions,TP=1):
FastAPI 收到请求 → AsyncLLM.generate():套 chat template、tokenize 成 1500 个 token,生成 EngineCoreRequest,经 ZMQ 发给 EngineCore;
EngineCore busy loop(不停轮询输入队列、有活就调度执行的主循环)收到后 scheduler.add_request() 放进 waiting 队列;
下一步 schedule():prefix cache 命中前 1024 个 token(system prompt),剩 476 个在 budget 内一次算完;allocate_slots 分配 30 个新 block;
Worker 的 GPUModelRunner 更新 persistent batch、构造 attention metadata(slot mapping = 本步新 token 的 KV 写到显存哪个槽位;block table = 每个请求的逻辑块 → 物理块映射,第 38 课),跑 forward + sample,得到第 1 个 token —— TTFT 结束 ;
之后每一步该请求只占 1 个 token 的预算,和其他请求一起 decode(CUDA Graph replay);
每个新 token 经 ZMQ 回到 API Server,detokenize 后流式推送;遇到 EOS/stop 串/max_tokens,Scheduler 释放其 block(放回空闲队列、哈希保留供以后复用)。
关键源码路径 (vllm-project/vllm main 分支):
入口:vllm/entrypoints/openai/api_server.py
前端引擎:vllm/v1/engine/async_llm.py、input_processor.py、output_processor.py、core_client.py
核心循环:vllm/v1/engine/core.py(EngineCore / EngineCoreProc / DPEngineCoreProc)
调度:vllm/v1/core/sched/scheduler.py、async_scheduler.py
KV 管理:vllm/v1/core/kv_cache_manager.py、block_pool.py、kv_cache_utils.py
执行:vllm/v1/executor/multiproc_executor.py、vllm/v1/worker/gpu_worker.py、gpu_model_runner.py
其他:vllm/v1/attention/(attention backend)、vllm/v1/spec_decode/、vllm/v1/structured_output/、vllm/compilation/
进阶 :--async-scheduling 让调度下一步与执行当前步重叠(思路与 SGLang 的 overlap scheduler 相同);DP 部署时每个 DP rank 一个 EngineCore,由 coordinator.py 协调。
💬 能聊的要点
「vLLM V1 是三层多进程:API Server 做 tokenize/detokenize,EngineCore 跑调度 busy loop,每张 GPU 一个 Worker 跑模型,靠 ZeroMQ 和共享内存通信,把 Python CPU 开销从 GPU 关键路径上拿掉。」
「V1 Scheduler 不区分 prefill 和 decode,每步输出 {req_id: num_tokens},在统一 token budget 下 chunked prefill、prefix caching、spec decode 都是同一个『追赶 num_computed_tokens』模型的特例。」
「GPU 侧用 persistent batch 做增量更新、torch.compile 做融合、piecewise CUDA Graph 消除 decode 的 kernel launch 开销——小模型 decode 的瓶颈往往是 CPU 而不是 GPU。」
常见误区
❌ vLLM 的核心就是 PagedAttention kernel ✅ 今天 vLLM 的竞争力更多在调度、CPU 开销优化、编译和生态;attention kernel 本身多来自 FlashAttention/FlashInfer 等后端
❌ chunked prefill 是一个独立的调度模式 ✅ 在 V1 里它只是「token budget 不够时少给一点 token」的自然结果
❌ detokenize 在 GPU Worker 里做 ✅ 在 API Server 进程的 OutputProcessor 里做,和 GPU 执行并行
自测(心算/口答即可)
max_num_batched_tokens=2048,当前有 100 个 decode 请求,新来一个 5000 token 的 prompt,本步它能算多少 token?答案 先给 running 的 100 个 decode 请求各 1 个 token,剩 1948,新请求本步 prefill 1948 个 token,剩余部分下一步继续(chunked prefill)。
为什么把 EngineCore 放到单独进程能提高吞吐?答案 Python 有 GIL,tokenize/detokenize/HTTP 处理会抢占调度循环的 CPU 时间,导致 GPU 空等;拆进程后这些工作与调度和 GPU 执行真正并行。
prefix cache 的块在请求结束后立即被清除吗?答案 不会。引用计数归零后进入空闲队列但保留哈希,只有在需要新块时按 LRU 顺序被真正覆盖;在此之前命中可直接复用。
延伸
🎬 Karpathy:无直接对应视频
🎬 李沐:无直接对应视频
📄 vLLM 博客 vLLM V1: A Major Upgrade to vLLM's Core Architecture (2025-01-27);Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (SOSP'23, arXiv:2309.06180)
📄 源码:github.com/vllm-project/vllm 的 vllm/v1/;docs.vllm.ai 的 Architecture Overview / Design 文档
下一课 → 46 · SGLang 架构走读:前端 DSL、Radix 树、零开销调度、结构化输出
46 · SGLang 架构走读:前端 DSL、Radix 树、零开销调度、结构化输出
⏱ 10 分钟 | 前置:45 · vLLM 架构走读 | 难度:★★★
一句话
SGLang = 一个把 LLM 程序(多轮、分叉、约束输出)写成 Python 的前端 DSL (@sgl.function、gen、fork、select)+ 一个叫 SRT(SGLang Runtime) 的推理引擎:TokenizerManager → Scheduler(内含 TpModelWorker)→ DetokenizerManager 的多进程流水线,以 RadixCache 做跨请求 KV 复用,以 overlap scheduler (「零开销」调度)把 CPU 调度藏进 GPU 计算里,以 xgrammar / compressed FSM 做快速结构化输出。
为什么要关心
SGLang 与 vLLM 是目前最主流的两个开源推理引擎,DeepSeek、xAI 等的大规模部署和很多 RL 训练框架(用于 rollout:训练中让模型批量生成回答作为样本)都选它。理解两者设计上的异同(进程划分、KV 复用方式、调度重叠)是做选型、读代码、面试对比题的基础。
核心直觉
前端:把 prompt 工程写成程序
SGLang 最初(论文 2023-12)的出发点是:真实应用不是单次 generate,而是「多次调用 + 分支 + 约束」的程序。DSL 让运行时看得见程序结构 ,从而知道哪些前缀可以共享。
import sglang as sgl
@sgl.function
def multi_view(s, question):
s += sgl.system("You are a careful analyst.")
s += sgl.user(question)
forks = s.fork(3) # 3 个分支共享同一前缀的 KV
for i, f in enumerate(forks):
f += sgl.assistant(sgl.gen("view", max_tokens=128, temperature=0.8))
s += sgl.user("Combine the views: " + " | ".join(f["view"] for f in forks))
s += sgl.assistant(sgl.gen("verdict", regex=r"(Yes|No)\. .*")) # 约束解码
fork 出来的分支天然共享前缀 → 后端 RadixCache 只 prefill 一次;
gen(..., regex=...) / select([...]) → 后端的 grammar 约束解码。
今天大多数人直接用 SRT 的 OpenAI 兼容 API,DSL 用得少了,但「为程序结构优化」的思想决定了 SRT 的设计。
后端 SRT:四段式进程流水线
HTTP (entrypoints/http_server.py) / Engine (entrypoints/engine.py)
│
▼
TokenizerManager(主进程,managers/tokenizer_manager.py)
│ tokenize、多模态预处理,ZMQ 发送
▼
Scheduler(子进程,每个 TP rank 一个,managers/scheduler.py)
│ 接收请求 → schedule_policy 选 batch → RadixCache 匹配前缀
│ ScheduleBatch → ModelWorkerBatch → ForwardBatch
▼
TpModelWorker(managers/tp_worker.py)→ ModelRunner(model_executor/model_runner.py)
│ forward(CUDA Graph)+ sample
▼
Scheduler 处理结果(追加 token、检查结束、更新 radix 树)
│ ZMQ 发送 token id
▼
DetokenizerManager(子进程,managers/detokenizer_manager.py)
│ 增量 detokenize,ZMQ 回传
▼
TokenizerManager → 流式返回客户端
与 vLLM V1 对比:思想相同(CPU 杂活离开 GPU 主循环),但 SGLang 把 detokenize 也单拎成一个进程;Scheduler 与模型执行在同一进程 (TP 时每个 rank 都跑一份 Scheduler,由 rank 0 负责收发),vLLM 则是 EngineCore 与 Worker 分进程。
RadixCache:树形前缀复用
第 41 课讲过:所有请求的 token 序列组织成一棵 radix tree ,节点持有对应 KV 的索引;新请求做最长前缀匹配,命中部分免算;按叶子节点 LRU 淘汰、引用计数保护正在使用的节点。代码在 mem_cache/radix_cache.py,还有 HiCache(把 KV 分层卸载到 CPU 内存/存储,hicache_storage.py 等)。调度策略(schedule_policy.py)支持 LPM(longest prefix match) 等 cache-aware 排序:优先调度命中前缀长的请求,提高命中率。
Overlap Scheduler:「零开销」批调度
朴素循环是串行的:CPU 调度 → GPU forward → CPU 处理结果 → CPU 调度 → …,CPU 那两段期间 GPU 空闲。SGLang v0.4(2024-12)引入 overlap:CPU 提前一步 。
时间 →
GPU: [ forward batch N ][ forward batch N+1 ][ forward N+2 ]
CPU: [处理 N-1 结果 | 调度 N+1][处理 N 结果 | 调度 N+2]
关键技巧:调度 N+1 时还不知道 N 采样出的 token,于是用 future token 占位 ,GPU 上直接从 N 的输出 buffer 里取真值。
🧩 难点拆解:overlap scheduling 与 future token
直觉 :像 CPU 的指令流水线或 I/O 的双缓冲——不等上一件事彻底收尾,就先把下一件事准备好。难点在于有数据依赖:batch N+1 的输入就是 batch N 刚采样出的 token,CPU 此刻还不知道它是什么。解法类似编程里的 Future/Promise :CPU 先写一个「欠条」(占位符:只记下「这个 token 去 GPU 上某个 future buffer 的第几格取」),GPU 按 stream 顺序执行,跑到 N+1 时 N 已经把真 token 写进那一格,kernel 直接从 GPU 显存里取,全程不需要 CPU 等 GPU 。
小例子 (假设数字):GPU forward 每步 10 ms,CPU 每步的「处理结果 + 调度」共 3 ms。
串行 :每步 13 ms,GPU 利用率 10/13 ≈ 77%;
overlap :CPU 的 3 ms 藏在 GPU 的 10 ms 里,每步 ≈ 10 ms,吞吐约 1.3x ;
前提是 CPU 时间 < GPU 时间;若 CPU 要 12 ms,每步变成 12 ms,瓶颈转到 CPU。
一句话 :overlap 用「占位符 + GPU 上取真值」打破 N 与 N+1 的依赖,让 CPU 永远提前一步,调度开销从关键路径上消失。
官方称相对前一版本 1.1x 、相对其他基线 1.3x 吞吐,并把 radix cache 操作这类昂贵的 CPU 开销完全藏住。代码见 scheduler.py 的 event_loop_overlap()(对照 event_loop_normal())与 overlap_utils.py。遇到必须同步的情况(例如某些 grammar 或 spec 场景)会对该 batch 关闭 overlap。
结构化输出:compressed FSM → xgrammar
结构化输出 / constrained decoding (约束解码)= 保证模型输出一定符合某个格式(JSON schema、正则、语法)。核心手段是在每步采样前,把「会破坏格式」的 token 的 logits(模型对每个 token 打的未归一化分数)改成 −∞,让它们概率为 0。
🧩 难点拆解:FSM masking 怎么把输出「关进」格式里
直觉 :把格式编译成一个 FSM (Finite State Machine,有限状态机)——像一个只接受合法输入的解析器。每生成一个 token,状态往前走一步;每个状态下只有部分 token 能让解析继续,其余全部屏蔽。类比:表单输入框的实时校验,不合法的字符根本敲不进去。
小例子 :要求输出匹配正则 (Yes|No)\.,假设词表只有 6 个 token:Yes、No、Y、es、.、Maybe。
状态 0(开头) :合法的是 Yes、No、Y(Y 后面还能接 es);模型 logits 为 Maybe=3.0, Yes=2.0, No=1.0, …,Maybe 分最高,但被 mask 成 −∞,只能在 Yes/No/Y 里采样 → 采到 Yes;
状态 1(已输出 Yes) :只有 . 合法——只剩唯一 选择,不必跑模型,直接追加,这就是 jump-forward ;
状态 2 :到达终止状态,结束。
真实词表有 10 万级 token(Llama-3 是 128,256),每步要对每个请求算一个 128,256 位的 bitmask (约 16 KB):1 = 允许,0 = 屏蔽。逐 token 检查太慢,xgrammar 的做法是:大部分 token 在某个语法位置是否合法与「解析栈里之前是什么」无关(CFG 要用栈记住 JSON 嵌套到第几层,这部分才依赖上下文),可以离线预计算 ;运行时只需检查少数与上下文相关的 token。
一句话 :约束解码 = 格式编译成状态机 + 每步用 bitmask 屏蔽非法 token;唯一合法路径时直接 jump-forward,工程上的关键是把 mask 算得足够快、并与 GPU forward 重叠。
Compressed FSM (2024-02):把 JSON schema/regex 编译成有限状态机;若某段状态路径只有唯一合法走法(如 {"name": "),就把整段一次性 append(jump-forward) ,不必逐 token 解码。博客报告相比 guidance+llama.cpp、outlines+vLLM 延迟最多降 2x、吞吐最多升 2.5x。
xgrammar (CMU/MLC 团队的库):SGLang v0.4 起集成(v0.4 博客里需加 --grammar-backend xgrammar 开启;当前版本 --grammar-backend 未指定时默认就是 xgrammar)。支持完整 context-free grammar(CFG,上下文无关文法,比正则更强、能描述嵌套的 JSON;EBNF 是它的常用写法),把大部分 token 的合法性预计算 成 bitmask,运行时只需处理少量上下文相关 token,并与 GPU 计算重叠;v0.4 博客称 JSON 解码比其他开源方案快最多 10x 。代码在 constrained/xgrammar_backend.py,另有 llguidance_backend.py、outlines_backend.py。
代码片段
# event_loop_overlap 的骨架(简化自 python/sglang/srt/managers/scheduler.py)
def event_loop_overlap(self):
result_queue = deque()
while True:
self.ingest_requests() # 从 TokenizerManager 收新请求
batch = self.get_next_batch_to_run() # 调度:prefill 优先 / 合并 decode
if batch:
result = self.run_batch(batch) # 异步 launch 到 GPU,立即返回
result_queue.append((batch.copy(), result))
if self.last_batch: # GPU 跑 N 的同时,CPU 处理 N-1 的结果
tmp_batch, tmp_result = result_queue.popleft()
self.process_batch_result(tmp_batch, tmp_result) # 追加 token、更新 radix 树、发给 detokenizer
self.last_batch = batch
run_batch 只是把 kernel 排进 CUDA stream,不等结果;真正的同步点被推迟到下一轮的 process_batch_result。
这就是「CPU 提前一步」:GPU 始终有活干,调度开销从关键路径上消失。
工程视角 / 真实世界
关键源码路径 (sgl-project/sglang,python/sglang/ 下):
前端 DSL:lang/api.py(function、gen、select)、lang/interpreter.py、lang/ir.py
入口:srt/entrypoints/http_server.py、engine.py、openai/
进程与调度:srt/managers/tokenizer_manager.py、scheduler.py、schedule_batch.py、schedule_policy.py、tp_worker.py、detokenizer_manager.py、data_parallel_controller.py
KV 管理:srt/mem_cache/radix_cache.py、memory_pool.py、chunk_cache.py
执行:srt/model_executor/model_runner.py、forward_batch_info.py
其他:srt/constrained/、srt/speculative/、srt/disaggregation/、srt/layers/quantization/、压测脚本 benchmark/serving.py
特色能力 :DP-Attention + DeepEP 的大规模 EP(第 44 课);cache-aware router (sgl-router,仓库中现名 sgl-model-gateway,在路由层维护近似 radix 树,把相同前缀的请求发往同一 worker,v0.4 博客报告吞吐最多 1.9x、命中率 3.8x);作为 RL 训练的 rollout 引擎(verl、slime 等框架集成)。
💬 能聊的要点
「SGLang 的 SRT 是 TokenizerManager、Scheduler、DetokenizerManager 三类进程加 TpModelWorker 的流水线,用 ZMQ 串起来;和 vLLM V1 一样把 CPU 杂活移出 GPU 主循环,但 detokenize 单独成进程,调度和执行在同一进程。」
「overlap scheduler 让 CPU 提前一步:GPU 跑第 N 个 batch 时 CPU 处理第 N−1 个结果并调度 N+1,用 future token 占位解决依赖,把 radix 树维护这类开销完全藏在 GPU 时间里。」
「结构化输出从 compressed FSM 的 jump-forward 演进到 xgrammar:预计算大部分 token 的合法性 bitmask,运行时开销极低,还能与 GPU forward 重叠。」
常见误区
❌ 用 SGLang 就得写 sgl.function DSL ✅ 绝大多数生产用法是 OpenAI 兼容 HTTP API 或 Python Engine,DSL 是可选前端
❌ RadixAttention 是一个新的 attention kernel ✅ 它是 KV 复用的管理机制 (radix 树 + LRU + 调度策略),kernel 用的是 FlashInfer/FlashAttention 等后端
❌ 「零开销调度」意味着调度没有成本 ✅ 成本仍在,只是被 GPU 计算时间掩盖;遇到需同步的 batch 会退化为普通循环
自测(心算/口答即可)
overlap 调度时,batch N+1 需要 batch N 刚采样的 token 作为输入,但 CPU 还没拿到结果,怎么办?答案 先用 future token 占位(只记下「去 GPU 上 future buffer 第几格取」的索引),GPU 执行 N+1 时直接从 N 的输出 buffer 读取真值;CPU 侧稍后再处理 N 的结果。
s.fork(3) 后三个分支各生成 128 token,前缀 prefill 了几次?答案 一次。三个分支的前缀在 radix 树上是同一条路径,后两个分支直接命中。
compressed FSM 的 jump-forward 在什么情况下能省掉 forward?答案 当 FSM 当前状态只有唯一合法的后续字符串(如 JSON 固定的 key 和标点)时,可以直接把它们追加进序列,无需模型逐 token 生成。
延伸
🎬 Karpathy:无直接对应视频
🎬 李沐:无直接对应视频
📄 Zheng et al., SGLang: Efficient Execution of Structured Language Model Programs (NeurIPS 2024, arXiv:2312.07104);Dong et al., XGrammar (arXiv:2411.15100)
📄 LMSYS 博客:Fast JSON Decoding for Local LLMs with Compressed Finite State Machine (2024-02-05)、SGLang v0.4: Zero-Overhead Batch Scheduler, Cache-Aware Load Balancer, Faster Structured Outputs (2024-12-04);github.com/sgl-project/sgl-learning-materials
下一课 → 47 · 推理指标与选型:TTFT / TPOT / Throughput / Goodput,vLLM vs SGLang vs TensorRT-LLM
47 · 推理指标与选型:TTFT / TPOT / Throughput / Goodput,vLLM vs SGLang vs TensorRT-LLM
⏱ 10 分钟 | 前置:46 · SGLang 架构走读 | 难度:★★☆
一句话
LLM serving 的性能不是一个数:用户感受的是 TTFT (首 token 多久出来)和 TPOT/ITL (之后每个 token 多快),老板关心的是 Throughput (每卡每秒多少 token);三者此消彼长,正确的评价方式是在给定 SLO 下比 Goodput ,或者画出「单用户速度 vs 单卡吞吐」的 tradeoff 曲线——框架选型也要放在这条曲线上比。
为什么要关心
「vLLM 和 SGLang 哪个快?」没有脱离负载的答案:输入输出长度、并发度、前缀命中率、SLO、硬件任何一个变了,结论都可能反转。不会定义指标、不会压测的人,看 benchmark 只会被营销数字带着走;AI Infra 的日常工作恰恰是「在 SLO 约束下把成本压到最低」。
核心直觉
一个请求的时间轴
提交 ─┬─ 排队 ─┬─ prefill ─┬─ t1 ─ t2 ─ t3 ─ … ─ tn ─┤
│<────── TTFT ───────>│<── ITL ─>│ │
│<──────────────────── E2E latency ─────────────>│
TPOT = (E2E − TTFT) / (n − 1) # 单请求平均每个输出 token 的时间
指标
定义
主要受什么影响
用户感受
TTFT (Time To First Token)
请求到达 → 第 1 个 token
排队、prompt 长度(prefill)、prefix 命中
「卡不卡」
TPOT (Time Per Output Token)
首 token 后平均每 token 耗时(按请求算)
batch 大小、模型大小、带宽、spec decode
「打字速度」
ITL (Inter-Token Latency)
相邻两次 token 到达的间隔(按每个间隔统计)
同上,且能反映抖动 (被别人的 prefill 打断)
「是否一顿一顿」
E2E latency
请求到达 → 最后一个 token
≈ TTFT + TPOT × (n−1)
非流式场景唯一关心的
Throughput
requests/s、output tokens/s、total tokens/s
并发度、batch 效率
成本
Goodput
满足 SLO 的 请求数/秒(DistServe 提出)
以上全部
「真实可用的容量」
TPOT 和 ITL 的均值通常接近,但 P99 ITL 能暴露 TPOT 均值看不到的卡顿:一个长 prefill 混进 batch,会让同批所有请求在那一步多等几十到几百毫秒——这正是 chunked prefill 和 PD 分离要解决的问题。
SLO 与百分位
SLO (Service Level Objective)就是后端熟悉的服务等级目标。线上用 P90/P99 (90%/99% 的请求都不超过这个值)而不是平均值定 SLO,例如「P99 TTFT < 1s 且 P99 TPOT < 50ms」(具体数值取决于产品:聊天机器人对 TTFT 敏感;代码补全对 E2E 敏感;离线批处理只看吞吐和成本)。Goodput = 同时满足所有 SLO 的请求率 ,比「最大吞吐」更能反映真实容量:一个系统在 100 req/s 时吞吐很高,但 P99 TTFT 已经 10 秒,就等于不可用。
🧩 难点拆解:goodput vs throughput
直觉 :throughput 数的是「处理完了多少请求」,goodput 数的是「按时 处理完了多少请求」。类比快递:一天送出 1 万件(throughput),但只有 6000 件在承诺时效内送达(goodput)——对用户有意义的是后者。
小例子 (假设数据,SLO:TTFT < 1s 且 TPOT < 50ms,两条都满足才算 good):
到达率
Throughput
满足 SLO 的比例
Goodput
8 req/s
8 req/s
99%
≈ 7.9 req/s
12 req/s
12 req/s
90%
≈ 10.8 req/s
16 req/s
16 req/s
50%(排队变长,TTFT 爆了)
≈ 8 req/s
到达率从 12 加到 16,throughput 还在涨,goodput 却从 10.8 掉到 8;
所以只看 throughput 会把系统推到「看着忙、用户体验很差」的区间;
容量规划时应取 goodput 最高点附近(这里约 12 req/s)作为单实例上限,再按流量算实例数。
一句话 :throughput 是引擎视角的产能,goodput 是用户视角的有效产能;PD 分离、chunked prefill 等优化常常是 throughput 不变而 goodput 大涨。
Tradeoff 曲线:batch 是跷跷板
第 36 课的 roofline 告诉我们:decode 时增大 batch,每步读的权重不变但产出更多 token,单卡吞吐上升 ,而每步变慢一点,单用户 TPOT 变差 ;继续加到 compute-bound 后吞吐饱和、延迟陡增。
单卡吞吐 (tok/s/GPU)
▲ ●──●──● ← 饱和:再加并发只增延迟
│ ●
│ ●
│ ●
│● 每个点 = 一个并发度
└──────────────────────▶ 单用户速度 (tok/s/user = 1/TPOT)
(实际画图时常把 x 轴反向:越往右单用户越快、吞吐越低)
比较两个框架/配置,要看整条曲线(Pareto 前沿:在不牺牲一个指标的前提下另一个指标已无法再提升的那些点) :在你的 SLO(某个 x 值)处谁的 y 更高。只报「最大吞吐」或「单并发延迟」都是片面的。
🧩 难点拆解:Little's Law 与容量估算
直觉 :后端工程师熟悉的 Little's Law :L = λ × W,即「系统里同时在处理的请求数 = 到达率 × 每个请求停留的时间」。像一家餐厅:每分钟来 2 桌、每桌坐 30 分钟,店里平均就有 60 桌。对 LLM serving,「同时在处理的请求」每个都占着一份 KV Cache,所以 KV 显存决定了 L 的上限,进而决定了吞吐上限。
小例子 (Llama-3-8B 单卡 H100,KV 每 token 128 KiB):
假设给 KV 留 60 GiB → 能放 60 GiB ÷ 128 KiB = 491,520 个 token;
每个请求平均上下文 2000 输入 + 200 输出 = 2200 token(按满长度预留,保守估计)→ 最多同时约 491,520 ÷ 2200 ≈ 223 个请求(L 的上限);
若平均 E2E 延迟 W = 8 s,则最大吞吐 λ = L / W ≈ 223 / 8 ≈ 28 req/s;
想再提吞吐:要么降 W(更快的 kernel、spec decode),要么增大 L(FP8 KV、prefix 共享、更多卡)。但注意,L 变大通常会让每步 batch 变大、W 也变长——这就是上面那条 tradeoff 曲线。
一句话 :Little's Law 把「显存能放多少并发」「延迟多长」「吞吐多少」三件事用一个乘法连起来,是估算容量的第一步。
压测要控制的变量
输入/输出长度分布(ShareGPT 真实对话数据集、随机、固定长度)、到达过程(固定 QPS 的 Poisson 到达——请求间隔随机、模拟真实用户;vs 固定并发——同时始终保持 N 个请求在跑,像压测工具的 N 个线程)、prefix 命中率、是否流式、采样参数、warm-up、以及同样的精度(FP8 vs BF16)和同样的并行配置 。
代码片段
# vLLM:启动服务后压测,按 SLO 统计 goodput
vllm serve meta-llama/Llama-3.1-8B-Instruct --max-num-batched-tokens 8192
vllm bench serve --model meta-llama/Llama-3.1-8B-Instruct \
--dataset-name random --random-input-len 2000 --random-output-len 200 \
--num-prompts 1000 --request-rate 16 \
--percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,90,99 \
--goodput ttft:1000 tpot:50 # 单位 ms:同时满足才算「good」
# SGLang:同一套思路
python -m sglang.launch_server --model-path meta-llama/Llama-3.1-8B-Instruct
# (旧入口 python -m sglang.bench_serving 仍可用,但已标记 deprecated)
python -m sglang.benchmark.serving --backend sglang \
--dataset-name random --random-input-len 2000 --random-output-len 200 \
--num-prompts 1000 --request-rate 16
--request-rate 扫一组值(如 1, 2, 4, …, inf),每个点记录吞吐和 P99 延迟,就能画出上面的 tradeoff 曲线。
--goodput 的定义直接引用 DistServe 论文,输出里有 request_goodput 字段。
SGLang 的压测脚本也支持 --backend vllm,可以用同一个客户端 压两个引擎,减少客户端差异。
工程视角 / 真实世界
vLLM
SGLang
TensorRT-LLM
出身
UC Berkeley Sky Lab,现为 PyTorch Foundation 项目
LMSYS / UC Berkeley 等,社区 + 多家公司
NVIDIA
核心卖点
生态最广:模型覆盖、硬件覆盖(NVIDIA、AMD、TPU、Intel、各类插件)
RadixCache、overlap 调度、结构化输出、DeepSeek 大规模 EP/PD
最新 NVIDIA 硬件(Hopper/Blackwell)上的极致 kernel 与 FP8/FP4
技术形态
Python + torch.compile + 多后端 kernel
Python + FlashInfer 等 kernel
1.0 起以 PyTorch 后端为默认(官方称 PyTorch-native 架构),早期「先 build TensorRT engine 再跑」的流程退居 legacy
硬件
最广
NVIDIA、AMD 为主,也支持其他
仅 NVIDIA
适合
默认首选、多模型多硬件平台、需要稳定和生态
多轮对话/agent/RAG 等前缀复用重的负载、MoE 大规模部署、RL rollout
深度绑定 NVIDIA、追求单位成本极限、配合 Triton/Dynamo
其他常见选择:
LMDeploy (上海 AI Lab / InternLM 团队):TurboMind(C++/CUDA)+ PyTorch 两套引擎,4-bit 量化推理性能好,国内 InternLM/Qwen 用户较多。
llama.cpp / Ollama :C/C++ 实现、GGUF 格式,CPU、Apple Silicon(Metal)、消费级显卡都能跑;面向本地单用户 ,并发吞吐不是它的目标。Ollama 在其上封装了模型下载和管理。
编排层 :NVIDIA Dynamo、llm-d 等不替代引擎,而是在 vLLM/SGLang/TRT-LLM 之上做 PD 分离、KV-aware 路由和扩缩容。
诚实的选型建议 :
先用 vLLM 或 SGLang 跑通,二者对主流模型都有 day-0 支持,功能差距在逐版缩小;
用自己的真实流量分布 (长度、前缀、并发)在目标 SLO 下测 goodput,而不是看别人的博客数字;
前缀共享多、DeepSeek 类 MoE 大规模部署 → 重点测 SGLang;硬件或模型种类杂、需要最广生态 → vLLM;全栈 NVIDIA、有工程资源深调、追求 Blackwell 上极限性能 → 评估 TensorRT-LLM;
本地/边缘 → llama.cpp / Ollama。
💬 能聊的要点
「LLM serving 至少看三个数:TTFT 决定首响应,TPOT/ITL 决定输出速度,吞吐决定成本;它们互相拉扯,所以应该在 SLO 下比 goodput,或者比整条『单用户速度 vs 单卡吞吐』的 Pareto 曲线。」
「平均 TPOT 好看不代表体验好,P99 ITL 能暴露长 prefill 插队造成的卡顿,这正是 chunked prefill 和 PD 分离的动机。」
「框架没有绝对快慢:vLLM 生态最广、SGLang 在前缀复用和 DeepSeek 大规模 EP 上有优势、TensorRT-LLM 在最新 NVIDIA 硬件上最深;选型一定要用自己的流量分布测。」
常见误区
❌ 只报「最大吞吐 xx tok/s」就能说明快 ✅ 必须同时给出对应的并发度和 P99 TTFT/TPOT,否则可能是用不可接受的延迟换来的
❌ TPOT 和 ITL 是一回事 ✅ TPOT 按请求平均,ITL 按每个 token 间隔统计,后者能看到抖动和尾延迟
❌ 别人的 benchmark 结论可以直接套用 ✅ 输入输出长度、前缀命中率、精度、并行配置、框架版本任何一项不同,结论都可能反转
自测(心算/口答即可)
一个请求 TTFT=400ms,共输出 201 个 token,E2E=10.4s,TPOT 是多少?答案 (10.4 − 0.4) / (201 − 1) = 50ms,约 20 tokens/s。
服务平均 E2E 延迟 8 秒,要支撑 5 req/s,系统里平均同时有多少个在跑的请求?答案 Little's Law:5 × 8 = 40 个并发请求。KV Cache 要按 40 个请求的上下文来预留。
框架 A 最大吞吐比 B 高 20%,但在你的 SLO(P99 TPOT<40ms)下 goodput 比 B 低,应该选谁?答案 选 B。最大吞吐通常出现在延迟已经超标的高并发区间,对你的业务没有意义;goodput 才是满足 SLO 下的真实容量。
延伸
🎬 Karpathy:无直接对应视频
🎬 李沐:无直接对应视频
📄 Zhong et al., DistServe (goodput 定义,arXiv:2401.09670);Hao AI Lab 博客 Throughput is Not All You Need
📄 源码与文档:vllm/benchmarks/serve.py(vllm bench serve)、python/sglang/benchmark/serving.py;TensorRT-LLM 文档 github.com/NVIDIA/TensorRT-LLM;LMDeploy github.com/InternLM/lmdeploy;llama.cpp github.com/ggml-org/llama.cpp
下一课 → AI Infra 知识地图与转岗路线
AI Infra 知识地图与转岗路线
⏱ 15 分钟 | 适合:学完 Part 5 后读,或者一开始就读来定方向
一句话
AI Infra 本质上是「在 GPU 这种又贵又怪的硬件上做分布式系统」——你已有的 distributed systems、scheduling、storage、observability 经验大部分能迁移,缺的是 GPU 性能模型 + 模型结构对系统的影响 这两块,而本课程主要就是补这两块。
AI Infra 的版图
┌─────────────────────────── 应用层 ───────────────────────────┐
│ Agent / RAG / Eval 平台 · LLM Gateway · Prompt/Context 管理 │
└───────────────────────────────┬──────────────────────────────┘
┌────────── Serving (推理) ──────────┐ ┌──────── Training (训练) ────────┐ ┌──── RL / Post-train ────┐
│ Inference Engine: vLLM / SGLang / │ │ Megatron-LM / DeepSpeed / FSDP │ │ verl / OpenRLHF / slime │
│ TensorRT-LLM (batching, KV) │ │ 3D/4D Parallelism, Checkpoint │ │ Rollout = Inference │
│ Serving Platform: 路由, autoscale, │ │ 容错, 训练稳定性, MFU 优化 │ │ Train↔Rollout 权重同步 │
│ PD 分离, KV Cache 存储 (Mooncake) │ │ │ │ Reward / Sandbox 服务 │
└──────────────────┬─────────────────┘ └───────────────┬─────────────────┘ └────────────┬────────────┘
└──────────────────┬───────────────────┴──────────────────────────────────┘
┌──────────── Kernel / Compiler ─────────────┐ ┌──────── Cluster / Platform ────────┐
│ CUDA, Triton, CUTLASS, FlashAttention, │ │ GPU 调度 (K8s, Volcano, Kueue), │
│ FlashInfer, DeepGEMM, torch.compile, 量化 │ │ RDMA/NCCL 网络, 存储, 故障检测 │
└─────────────────────────────────────────────┘ └────────────────────────────────────┘
Data Infra:预训练数据 pipeline、去重、合成数据、Eval 数据
岗位类型 × 你的起点
方向
做什么
核心技能
对后端/数据工程师的门槛
对应课程
Serving Platform / LLM Gateway
多模型路由、autoscaling、PD 分离编排、KV-aware routing、多租户、限流计费
分布式系统、K8s、网络、可观测性 + 推理指标
⭐ 最低,最容易切入
36–39, 41, 44, 47
Inference Engine
改 vLLM/SGLang:scheduler、KV cache 管理、spec decoding、新模型适配
Python + PyTorch、GPU 性能模型、模型结构
⭐⭐ 需要读懂模型和引擎源码
19–26, 35–46
RL / Post-train Infra
rollout 引擎集成、train/infer 权重同步、异步 RL、reward 服务、代码执行 sandbox
训练 + 推理两边都懂,分布式调度
⭐⭐ 近两年增长最快的方向之一
31–33, 29–30, 45
Training Infra
大规模训练框架、并行策略、checkpoint、容错、MFU 优化
并行策略、NCCL、PyTorch 分布式
⭐⭐⭐
28–30
Kernel / Compiler
写 CUDA/Triton kernel、attention/GEMM/MoE kernel、量化 kernel
CUDA、GPU 架构、数值计算
⭐⭐⭐⭐ 需要大量 hands-on
35, 40, 40a, 43
Cluster / GPU Platform
GPU 集群调度、RDMA 网络、故障检测、资源利用率
基础设施、调度、网络
⭐ 与现有经验高度重合
29, 35, 44
Data Infra for LLM
预训练/SFT 数据 pipeline、去重、质量打分、合成数据
Spark/大数据、数据质量
⭐ 与数据平台经验直接重合
27
建议 :以你的背景(后端 + 数据平台),最顺的切入点是 Serving Platform 、RL Infra (它的大部分工作是调度和系统编排)或 Data Infra for LLM ,站稳后再往 Inference Engine 深入。Kernel 方向需要大量 hands-on,除非特别感兴趣,否则不必作为首选。
必须能讲清楚的 20 个问题(面试 / 讨论高频)
按「能讲出来 = 真懂」的标准自查,括号里是对应课程:
一个 decoder-only Transformer 的一层里有什么?参数主要在哪?(14–16)
为什么 decode 是 memory-bound?batch size 为什么能提升吞吐却几乎不增加单步延迟?(36)
KV Cache 怎么算?Llama-3-8B 跑 8K context、batch 32 要多少显存?(37)
MHA / MQA / GQA / MLA 的区别和对 KV Cache 的影响?(19–20)
MoE 为什么「总参数大、激活参数小」?对推理系统带来什么新问题(EP、all-to-all、负载不均)?(21–22, 44)
PagedAttention 解决了什么问题?和 OS 虚拟内存怎么类比?(38)
Continuous batching 和 static batching 的区别?chunked prefill 解决什么?(39)
FlashAttention 为什么快?它减少了 FLOPs 吗?(40:没有,减少的是 HBM 读写)
Prefix caching 在 vLLM 和 SGLang 中实现有什么不同?什么业务场景收益最大?(41)
Speculative decoding 为什么能加速且不改变输出分布?什么时候反而变慢?(42)
AWQ/GPTQ 和 FP8 的区别?weight-only 量化为什么能加速 decode?(43)
TP 和 PP 分别适合什么场景?TP 为什么通常不跨机?(30, 44)
什么是 PD 分离?为什么要分?代价是什么(KV 传输)?(44)
TTFT、TPOT、throughput、goodput 各是什么?怎么权衡?(47)
vLLM 中一个请求从 HTTP 进来到 token 流出去经过了哪些组件?(45)
训练时显存都去哪了?为什么说 Adam 混合精度每个参数 16 bytes?ZeRO-1/2/3 分别切了什么?(28–29)
SFT、RLHF、DPO、GRPO 的区别?为什么 RL 训练里 rollout(推理)是瓶颈?(31–33)
RoPE 是什么?长上下文扩展(YaRN 等)在改什么?(13, 23)
LoRA 为什么省显存?multi-LoRA serving 怎么做?(34)
Scaling law 说了什么?Chinchilla 最优是什么意思,为什么现在的模型都「过训练」?(18)
没时间 hands-on 时,性价比最高的「半 hands-on」
你不需要写训练代码,但以下几件事每件只要 30–60 分钟,对「真懂」的帮助极大:
读 config.json :去 Hugging Face 打开 Llama-3-8B、Qwen3、DeepSeek-V3 的 config.json,对照第 26 课逐字段解释。
心算显存 :对任意模型算「权重显存 + KV Cache 显存」,判断几张卡能跑、最多能并发多少请求(第 37 课公式)。
跑一次 vLLM / SGLang (有 GPU 时):vllm serve <model> 起服务,用 vllm bench serve 压测,观察 TTFT/TPOT 随并发的变化曲线。这比读十篇文章都直观。
跟一个 PR :在 vLLM / SGLang 仓库里找一个 「Add support for XXX model」 或 scheduler 相关的 PR,读 diff 和讨论。你会看到第 45/46 课的组件是怎么被改的。
看 Karpathy 的 nanochat / nanoGPT 仓库 :不跑,只读 model.py,对照第 16 课。
持续跟进的信息源
博客 :vLLM Blog(blog.vllm.ai)、LMSYS / SGLang Blog(lmsys.org/blog)、PyTorch Blog、Hugging Face Blog、Tri Dao 的博客、DeepSeek 在 GitHub 上发布的 tech report 与 Open Infra Week 仓库(FlashMLA、DeepEP、DeepGEMM、3FS 等)
课程 :Karpathy「Neural Networks: Zero to Hero」、李沐「动手学深度学习」+「论文精读」、Stanford CS336(Language Modeling from Scratch,本课程的对照与动手路线见 附录 )、CMU / Berkeley 的 ML Systems 课程
论文阅读顺序(Infra 向) :Attention Is All You Need → FlashAttention → Orca(continuous batching)→ vLLM/PagedAttention → SGLang(RadixAttention)→ Speculative Decoding(Leviathan et al.)→ DistServe / Splitwise / Mooncake → DeepSeek-V3 Technical Report → Megatron-LM → ZeRO
社区 :vLLM / SGLang 的 GitHub issues 与 roadmap issue、各自的 Slack 社区
一个可执行的 8 周计划(每天 10–20 分钟)
周
内容
产出(自己能讲出来)
1
Part 0–1
训练 = 前向 + loss + 反向 + 更新;LM = 预测下一个 token
2
Part 2
画出一个 Transformer block,说出每层 shape
3
Part 3(18–22,含 18a)
GQA/MLA/MoE 的动机与对 infra 的影响
4
Part 3(23–26)+ 读 3 个 config.json
能解读任一新模型的架构卡片
5
Part 4(含 34a、34b)
训练显存账本 + 3D 并行 + SFT/RL 流程 + 会看评测表
6
Part 5(35–41,含 40a)
推理性能模型 + KV Cache 管理全家桶
7
Part 5(42–47)
vLLM/SGLang 架构图 + 指标体系
8
刷闪卡 + 回答上面 20 个问题 + 读一篇新论文
能做一次 30 分钟的 LLM Infra 分享
回到首页 → README
CS336 对照与动手路线:读懂之后,亲手造一个
⏱ 15 分钟 | 适合:想知道本课程和 Stanford CS336 的关系,或者读完一个 Part 后想动手验证
一句话
Stanford CS336: Language Modeling from Scratch (Percy Liang、Tatsunori Hashimoto)借鉴操作系统课「从零写一个 OS」的思路,让学生亲手实现 tokenizer、Transformer、优化器、Triton kernel、分布式训练、数据管线和 RL,代码量比普通课程大一个数量级。本课程的定位是读懂、能聊、能估算 ,两者是互补关系:本课程 ≈ CS336 的「讲义层」,CS336 的作业 ≈ 本课程可选的「动手层」 。
两门课的定位对比
本课程(10 分钟一课) CS336
目标读者 想看懂 LLM 与 AI Infra 的工程师(后端 / 数据 / 平台) 想亲手造 LLM 的研究生,需要较强的 PyTorch 和系统基础
单位时间 每课 10 分钟,碎片化阅读 每周 2 节 80 分钟课 + 每 2–3 周一个大作业(5 学分)
代码 只读懂,≤30 行片段 + 逐行解读 极少脚手架,从空文件写到能训练
算力 不需要 A1 / A4 / A5 训练和 A2 benchmark 需要 GPU(课程主页给出的 2026 年 3 月 B200 云价约 $5–7/小时),建议先在 CPU 上调通
覆盖重点 架构 + 训练 + 推理 Infra 很深 (vLLM / SGLang 源码走读、调度、PD 分离) 全流程 + 动手深度 (kernel、并行、数据、RL 都要自己写)
产出 能讲清原理、能做 napkin math、能读源码 一个自己从零训出来的模型
CS336 讲次 ↔ 本课程(Spring 2026 课表)
# CS336 主题 本课程对应 备注
1 Overview, tokenization 00 、06
2 PyTorch (einops), resource accounting 09 、16 (参数量)、18 (6ND)、28 (16 B/param)、35 (算术强度)资源账本分散在多课,建议连读
3 Architectures, hyperparameters 13 –16 、25 、26 、🆕 18a 18a 补了形状超参的经验区间
4 Attention alternatives, MoE 19 –24
5 GPUs, TPUs 35 、36 TPU 本课程未展开
6 Kernels, Triton 40 、🆕 40a
7–8 Parallelism 29 、30 、44
9, 11 Scaling laws 18 、🆕 18a
10 Inference 36 –47 本课程这部分比 CS336 更深
12 Evaluation 🆕 34a
13–14 Data(sources;filtering, dedup, mixing, synthetic) 27 CS336 用两讲,动手可做 A4
15 Mid / post-training(SFT / RLHF) 31 、32
16 Post-training, RLVR 33
17 Alignment, multimodality 32 、🆕 34b
18–19 Guest lectures —
CS336 的课程录像在 YouTube 上公开(课程主页有播放列表),可以把它当作本课程每个 Part 的「长视频版」:读完一个 Part 的 10 分钟课,再挑对应讲次看 80 分钟深入版。
5 个作业 ↔ 本课程,以及「最小动手版」
CS336 作业完整做完每个要几十小时。下面每个作业都给出一个最小动手版 :挑最能验证理解的一小块,几小时内能完成,多数不需要大 GPU。作业细节以 CS336 官方 handout 为准(下表按 2025 版 handout 概括,2026 版可能有调整)。
作业 要做什么 先读这些课 最小动手版
A1 Basics 从零实现 BPE tokenizer、Transformer LM(RMSNorm、RoPE、SwiGLU、attention)、cross-entropy、AdamW、学习率调度、训练循环,并在 TinyStories 等数据上训练 06 、08 、09 、11 –16 ① CPU 上训练一个 1K 词表的 BPE,验证 encode/decode 往返一致;② 单卡(或 Mac)训一个几百万参数的 TinyStories 模型,看 loss 曲线并生成故事
A2 Systems benchmark / profile A1 的模型,混合精度,用 Triton 手写 FlashAttention-2(forward + backward),实现 DDP(含通信重叠、分桶)和优化器状态切分 28 、29 、35 、40 、🆕 40a ① 用 torch.profiler 测出 forward / backward / optimizer 各占多少时间;② 照着 40a 写 Triton fused softmax 并与 naive 版对比带宽(云上或 Colab 的 NVIDIA GPU 即可)
A3 Scaling 理解各 Transformer 组件的作用;在 FLOPs 配额内调用训练 API,拟合 scaling law,预测大预算下的最优模型 18 、🆕 18a 运行 18a 的 IsoFLOP 代码,把黑盒换成「加了 ±1% 噪声的 loss」,观察外推结果有多不稳定,再思考 FLOPs 配额该怎么分配
A4 Data 把 Common Crawl 原始 WARC / WET 转成训练数据:正文抽取、语言识别、PII 脱敏、有害内容与质量过滤、精确去重 + MinHash 去重 27 下载一个 WET 文件(约百 MB),写 fastText 语言识别 + 几条 Gopher 规则 + MinHash 去重,统计每一步过滤掉多少比例
A5 Alignment & Reasoning RL 在数学题上做 SFT 和 RL(GRPO)训练推理能力;可选部分做 DPO 等安全对齐 31 、32 、33 、🆕 34a 用 vLLM 跑一个 1.5B 级模型在 GSM8K 子集上的 baseline,写答案抽取 + reward 函数,算出 avg@8 和 pass@8(34a 的公式)
三条路线,按你的时间选
📖 只读路线(原定位) :按首页推荐路径读完全部课程,刷闪卡。完全不需要 GPU。
🔧 读 + 最小动手(推荐给想转 Infra 的人) :每读完一个 Part,做对应作业的「最小动手版」:Part 1–2 → A1,Part 3 → A3,Part 4 → A4 / A5,Part 5 → A2。总计约 5 个周末。
🏗 完整 CS336 :按 CS336 课表做完 5 个作业,本课程当作每讲的预习材料和事后速查。预计一个学期,需要几十到上百美元云 GPU 预算。
动手时的两个提醒
先在 CPU / 小规模上调通 :CS336 主页同样建议先在 CPU 上调试,再上 GPU 跑完整实验。大部分 bug 在玩具尺寸上就能复现,GPU 时间要花在确实需要的地方。
AI 工具的用法 :CS336 允许用 LLM 问概念和底层编程问题,但不允许直接让它解题,并建议关掉 AI 自动补全。自学时同理:可以让 Claude 讲解概念、帮你 review,但核心代码自己敲,动手层的价值在于暴露你没真懂的地方。
延伸
🎓 课程主页:cs336.stanford.edu(课表、作业 handout 与 GitHub 仓库、YouTube 录像列表)
🎬 Karpathy:Neural Networks: Zero to Hero 系列,可作为 A1 的前置热身(micrograd → makemore → Let's build GPT)
📖 本课程 视频对照表 、AI Infra 知识地图与转岗路线
回到首页 → README
闪卡(Flashcards)
用法:碎片时间刷。先看 Q: 自己口答,再点开 A 对答案;答不上来就点末尾的课号回原课。顶部「面试高频 20 题」对应 career-map 的 20 个问题,适合面试前冲刺;后面按 Part 分组,偏重「为什么 / 对比 / 取舍 / 算数字」。数字均来自课程正文,心算时允许「约」。
目录 :面试高频 20 题 · Part 0–1 基础 · Part 2 Transformer · Part 3 现代架构 · Part 4 训练 · Part 5 推理 Infra · 🆕 CS336 补充课
面试高频 20 题
Q: 1. 一个 decoder-only Transformer 的一层里有什么?参数主要在哪?
A Pre-Norm 结构:x += Attn(RMSNorm(x))(QKV 投影 → RoPE → causal attention → W_O),再 x += FFN(RMSNorm(x))(SwiGLU 的 gate/up/down)。参数主要在 FFN:GPT-2 式(MHA + 4 倍宽 MLP)每层 attention 4d²、FFN 8d²,FFN 约占 2/3,整模型非 embedding 参数 ≈ 12·L·d²;Llama 这类 GQA + 加宽 SwiGLU 的模型要按实际矩阵重算,Llama-3-8B 每层 attention 约 42M、FFN 约 176M,FFN 约占 80%。(14 15 16 )
Q: 2. 为什么 decode 是 memory-bound?为什么加大 batch 能提吞吐却几乎不增加单步延迟?
A decode 每步每请求只处理 1 个 token,却要把全部权重从 HBM 读一遍,线性层算术强度 ≈ 本步 token 数 ≈ batch,远低于 H100 的约 295 FLOP/byte。batch 个请求共用同一次权重读取,单步时间仍由「读 16GB 权重」主导,所以吞吐近线性增长、TPOT 几乎不变——直到 KV 读取变大或进入 compute-bound。(36 )
Q: 3. KV Cache 怎么算?Llama-3-8B 跑 8K context、batch 32 要多少显存?
A 每 token = 2 × n_layers × n_kv_heads × head_dim × bytes = 2×32×8×128×2 = 128KB。8K 上下文每请求 1GB,× 32 = 32GB,再加权重约 16GB,总计约 48GB(未计激活与碎片),单张 80GB 卡放得下。(19 37 )
Q: 4. MHA / MQA / GQA / MLA 的区别和对 KV Cache 的影响?
A MHA 每个 Q 头一组 KV;MQA 所有头共享 1 组(最小但质量有损、TP 下要复制);GQA 分组共享,KV 缩小 n_head/n_kv_head 倍(Llama-3-8B 为 4 倍),质量几乎不掉;MLA 只缓存低秩 latent(512 + 64 维 RoPE key),DeepSeek-V3 每 token 约 69KB,比 Llama-3-70B 的 GQA(320KB)还小,靠 weight absorption 免解压。(19 20 )
Q: 5. MoE 为什么「总参数大、激活参数小」?给推理系统带来什么新问题?
A FFN 被换成 N 个 Expert,Router 为每 token 只选 top-k 个,所以计算按激活参数(V3:37B)、显存按总参数(671B)。新问题:所有 Expert 常驻显存;EP 下每层两次 all-to-all;小 batch 时每个 Expert 只分到零星 token,变成 memory-bound 的 GEMV;热门 Expert 造成负载不均(需要 EPLB 冗余副本)。(21 22 44 )
Q: 6. PagedAttention 解决了什么问题?和 OS 虚拟内存怎么类比?
A 以前按 max_len 预留连续显存,真正存 token 的只有 20–40%。PagedAttention 把 KV 切成固定 block(默认 16 token)= 页,block table = 页表,按需分配 = demand paging,引用计数 + copy-on-write = fork 共享(V1 改由 prefix caching 共享 prompt block)。消除外部碎片,内部碎片只在最后一个 block,利用率约 96%,吞吐 2–4 倍。(38 )
Q: 7. Continuous batching 和 static batching 的区别?chunked prefill 解决什么?
A static batching 是请求级:一批跑到全部结束,短请求陪长请求空转;continuous batching(Orca)每个 token step 重组 batch,结束的走、新的进。chunked prefill 用 token budget 把长 prompt 切块与 decode 混跑,避免一个大 prefill 让整批 decode 卡顿(TPOT 尖刺),代价是 TTFT 略增。(39 )
Q: 8. FlashAttention 为什么快?它减少了 FLOPs 吗?
A 没有,backward 还要重算,FLOPs 反而增加。快在 IO-aware:Q/K/V 分 tile 放进 SRAM,用 online softmax 边算边合并,N×N 分数矩阵从不写回 HBM,显存 O(N²) → O(N)。结果是精确 attention,不是近似。(40 )
Q: 9. Prefix caching 在 vLLM 和 SGLang 中的实现有什么不同?什么场景收益最大?
A vLLM APC:对写满的 block 做链式哈希(父哈希 + 本块 token),粒度是 block,释放的 block 保留哈希、按 LRU 淘汰。SGLang RadixAttention:radix tree 组织前缀,粒度可到 token,叶子优先 LRU,再加 LPM 等 cache-aware 调度。收益最大:多轮对话、agent / tool use、few-shot、固定长 system prompt、RAG。(41 )
Q: 10. Speculative decoding 为什么能加速且不改变输出分布?什么时候反而变慢?
A decode 时算力闲置,target 一次 forward 验证 γ 个 draft token 的代价约等于 decode 1 个;按 min(1, p/q) 接受、被拒则从归一化的 max(0, p−q) 重采样,数学上保证分布与 target 一致。高并发时 GPU 已接近 compute-bound,verify 被拒 token 的浪费计算不再免费,加速比可能 < 1;接受率低(开放式创作、高温)时收益很小,扣掉 draft / verify 开销可能不如不开。(42 )
Q: 11. AWQ/GPTQ 和 FP8 的区别?weight-only 量化为什么能加速 decode?
A AWQ/GPTQ 通常是 weight-only INT4(W4A16):只压权重,计算时反量化回 BF16;FP8 是 W8A8,权重和激活都低精度、直接跑 FP8 Tensor Core。decode 是 memory-bound,速度 ≈ 带宽 / 权重字节数,权重从 2 字节变 0.5 字节就少搬 4 倍数据,单请求 decode 理论上限约 4x(FP8 约 2x,实测因反量化等开销打折扣);但大 batch 进入 compute-bound 后 FP8 可能更快。(43 )
Q: 12. TP 和 PP 分别适合什么场景?TP 为什么通常不跨机?
A TP 切每层矩阵,能降单请求延迟,但每层 2 次 all-reduce(70B 80 层 decode 一步 160 次),延迟敏感、在关键路径上,所以只放 NVLink 域内(通常单机 TP ≤ 8,GB200 NVL72 可到 72)。PP 按层切,只在 stage 边界点对点传 activation,适合跨节点、「一台放不下」的场景,但单请求延迟不降反增。(30 44 )
Q: 13. 什么是 PD 分离?为什么要分?代价是什么?
A prefill(compute-bound)和 decode(memory-bound)放进不同 GPU 池,各自独立扩缩和选并行策略,消除长 prefill 对 decode 的干扰,目标从 throughput 变为 goodput(DistServe)。代价:KV Cache 要经 RDMA 等高速网络从 prefill 池传到 decode 池、两池配比难调、运维复杂;短 prompt 低并发时可能得不偿失。(44 )
Q: 14. TTFT、TPOT、throughput、goodput 各是什么?怎么权衡?
A TTFT = 到达到首 token(排队 + prefill);TPOT = (E2E − TTFT)/(n−1),输出速度;throughput = 每秒请求或 token 数,决定成本;goodput = 同时满足 SLO 的请求率。加大 batch 提吞吐但拉高 TPOT,所以应在 SLO(如 P99 TPOT < 50ms)下比 goodput,或比「单用户速度 vs 单卡吞吐」的 Pareto 曲线。(47 )
Q: 15. vLLM 中一个请求从 HTTP 进来到 token 流出去经过了哪些组件?
A FastAPI → AsyncLLM(套 chat template、tokenize)→ ZMQ → EngineCore busy loop:Scheduler 放入 waiting、在 token budget 内调度,KVCacheManager 查 prefix cache、分配 block → Executor → Worker 的 GPUModelRunner(更新 persistent batch、构造 block table 等 metadata、forward + sample)→ 结果经 ZMQ 回 API Server 的 OutputProcessor detokenize → SSE 流式返回;结束后 block 回空闲队列、哈希保留。(45 )
Q: 16. 训练时显存都去哪了?为什么说 Adam 混合精度每参数 16 bytes?ZeRO-1/2/3 分别切了什么?
A 权重 + 梯度 + 优化器状态 + activations。16B = BF16 权重 2 + 梯度 2 + FP32 master 4 + Adam m 4 + v 4(常用估算口径,不同实现 12–18B 浮动);7B 就是 112GB。ZeRO-1 切 optimizer states,ZeRO-2 再切 gradients,ZeRO-3(≈FSDP)再切 parameters,每卡模型状态降到约 16Ψ/N;activations 不被 ZeRO 切,要靠 checkpointing / SP / CP。(28 29 )
Q: 17. SFT、RLHF、DPO、GRPO 的区别?为什么 RL 训练里 rollout 是瓶颈?
A SFT:对话格式的 next-token 交叉熵,只算 assistant;RLHF:RM + PPO(4 个模型)+ KL;DPO:把 RLHF 目标改写成偏好对上的分类 loss,2 个模型、offline;GRPO:online RL,同题采 G 个回答,用「减组内均值、除组内 std」得到 advantage,去掉了 critic(仍保留 reference 算 KL),配合可验证奖励(RLVR)。rollout 就是大批量自回归推理,长度长尾,一个 batch 要等最长的生成完,占 RL step 大部分时间。(31 32 33 )
Q: 18. RoPE 是什么?长上下文扩展(YaRN 等)在改什么?
A RoPE 把 Q/K 每两维按「位置 × 频率」旋转,点积只依赖相对距离,零参数、不碰 V。超过训练长度时低频维度遇到没见过的角度导致失效;PI 整体压缩位置,NTK-aware 改 base,YaRN 按维度分段只插值低频,再做温度修正(q、k 各乘 0.1·ln s + 1,把长文本下变平的注意力拉尖)——本质都在改 RoPE 的频率 / 位置映射,痕迹在 rope_scaling。(13 23 )
Q: 19. LoRA 为什么省显存?multi-LoRA serving 怎么做?
A 冻结 W,只训 ΔW = B·A(r 常 8–64),base 参数不需要梯度和 Adam 状态,7B 从约 112GB 降到约 14GB 权重 + 少量 LoRA 状态(activation 不省)。serving 时不 merge:一个 base 常驻,base 部分做共享大 GEMM,各请求的 adapter 用 SGMV 类 kernel 分段计算,adapter 在 GPU/CPU 间按需换入换出(S-LoRA、Punica、vLLM --enable-lora)。(34 )
Q: 20. Scaling law 说了什么?Chinchilla 最优是什么意思?为什么现在的模型都「过训练」?
A loss 随 N、D、C 呈幂律平滑下降,可用小实验外推。Chinchilla:固定训练算力时 N 与 D 等比例增长,约 20 tokens/参数最优。但它只算训练成本;推理成本 ∝ 参数量且持续发生,所以业界用更小的模型喂远超比例的数据(Llama-3-8B 训 15T,约 1900 tokens/参数,是 Chinchilla 比例的近百倍)。(18 )
Part 0–1 · 开篇与基础
Q: 训练和推理的瓶颈为什么不同?
A 训练一次 forward 处理大 batch × 长序列,每读一次权重做很多计算,瓶颈在 FLOPs 和多卡通信;推理 decode 每步每请求只 1 个 token,瓶颈在显存容量和带宽。(00 )
Q: 一个开源 LLM 发布时你拿到的四样东西分别对应什么?
A config.json(架构)、*.safetensors(权重)、tokenizer.json(BPE 词表与 merges)、generation_config.json(默认采样参数)。Karpathy 的说法:LLM 本质是「参数文件 + 几百行推理代码」两个文件。(00 )
Q: 70B 模型 BF16 权重多大?INT4 呢?
A 70B × 2B ≈ 140GB;INT4 约 35GB(还要加 scale 等元数据,实际略大)。(01 )
Q: 训练 loss 持续下降、验证 loss 先降后升,说明什么?LLM 预训练常见吗?
A 过拟合。预训练数据通常只过约 1 个 epoch,每个 batch 都是新数据,train/val 几乎重合,很少过拟合;SFT 数据小、多 epoch 才容易出现。(01 09 )
Q: 梯度指向哪个方向?为什么更新时是减号?
A 梯度指向 loss 上升最快的方向,所以 θ -= lr × grad 朝反方向走。梯度只是局部信息,lr 太大会越过谷底甚至发散成 NaN。(02 )
Q: 为什么不用数值求导,而要用 backprop?
A 数值求导要把每个参数单独扰动一次,N 个参数就要约 N 次 forward,8B 参数不可行。backprop 一次 forward + 一次 backward(backward 约 2 倍 forward,合计约 3 倍)就算出全部梯度,这个倍数与参数量无关。(02 03 )
Q: 训练 FLOPs 为什么 ≈ 6ND?70B 模型训 2T tokens 约多少?
A forward 每 token 约 2N(每参数一次乘加),backward 约 4N,合计 6N/token。6 × 70e9 × 2e12 ≈ 8.4e23 FLOPs。(03 18 )
Q: 为什么训练比推理费显存得多(除了优化器状态)?
A backward 需要 forward 的中间 activations,要一直存到用完,随 batch × seq 增长;推理不做 backward,层间 activations 用完即丢,长期保留的只有 KV Cache。(03 )
Q: 为什么 PyTorch 要 zero_grad()?这个特性还带来了什么?
A 梯度默认累加(+=,同一变量多条路径的梯度要相加),不清零会叠到下一步。同一特性让 gradient accumulation 成立:多次 backward 不清零再 step 一次,等效大 batch。(03 )
Q: 为什么网络里必须有非线性激活?激活函数是计算瓶颈吗?
A 没有非线性,W₂(W₁x) = (W₂W₁)x,多层塌缩成一层。激活是逐元素的,FLOPs 可忽略,但单独 kernel 要整张量读写一遍 HBM,所以要和 GEMM 做 kernel fusion。(04 )
Q: (512×4096) @ (4096×14336) 的 FLOPs 约多少?
A 2 × 512 × 4096 × 14336 ≈ 6.0e10,约 60 GFLOPs(2mkn 公式)。(04 )
Q: 为什么训练可以高度并行,而生成必须串行?
A 训练用 teacher forcing:每个位置用真实前文,靠 causal mask 一次 forward 得到 T 个预测;推理时第 t 个 token 依赖第 t−1 个的采样结果,只能一个一个来。这个不对称是 Prefill/Decode 与 KV Cache 的起点。(05 )
Q: 为什么 base model 问「法国首都是?」可能不回答?
A 它的目标是续写互联网文档,这类问句后面常跟另一道题或论坛回复;知识其实已有,缺的是「助手行为格式」,要靠 post-training。(05 )
Q: 词表从 32K 扩到 128K 有什么取舍?
A 同样文本 token 更少:prefill 计算、KV Cache、decode 步数都减少,等效上下文更长,多语言更省;代价是 embedding / LM head 变大、softmax 更贵。Llama 3 为多语言和压缩率扩到 128,256。(06 )
Q: 为什么 LLM 数不清 strawberry 里有几个 r、算术偶尔出错?
A 模型看到的是 token id 而非字母;单词被切成 2–3 个子词,数字被切成不规则块(1234 → 123+4),字母和位对齐信息没直接暴露给模型。(06 )
Q: LLM 的 embedding 层和 RAG 用的 embedding 模型是一回事吗?
A 不是。前者是逐 token 的 V×d 查找表,是 LLM 的第一层;后者(BGE、text-embedding 系列)是专门训练的独立模型,把整段文本编码成一个向量用于检索。(07 )
Q: 词表 128,256、d = 4096 的 embedding 多大?为什么小模型更爱 weight tying?
A 约 5.25 亿参数,BF16 约 1.05GB;不 tie 的 LM head 再加一份。小模型里 V×d 占比高:GPT-2 small 的 embedding 38.6M 占 124M 的约 31%,tie 后省下这 38.6M(不 tie 时约 163M,省约 24%);大模型占比小,一般不 tie。(07 16 25 )
Q: V = 32,000 的模型第 0 步 loss 是 25,正常吗?
A 不正常。随机初始化应约 ln 32000 ≈ 10.4;25 说明初始 logits 过大、对错误答案过度自信,应缩小输出层初始化。(08 )
Q: Temperature 从 1 调到 0.5 会改变 greedy 输出吗?调高会让模型更聪明吗?
A 不会改变 greedy:它只缩放 logits,不改排序。调高只是把分布拍平,低概率 token 更容易被采到,更多样也更容易胡说。(08 17 )
Q: 为什么大词表模型训练时 logits 是显存隐藏大户?怎么解决?
A logits 形状 (B·T) × V:8×8192 个 token × 128,256 ≈ 84 亿个数,FP32 约 34GB。用 fused linear + cross-entropy(如 Liger Kernel)按 chunk 计算,不物化整个张量。(08 )
Q: micro-batch 8 × 4096 token、累积 4 步、64 卡 DP,global batch 多少 token?
A 8 × 4096 × 4 × 64 ≈ 8.4M tokens。(09 )
Q: 为什么需要 warmup?AdamW 比 Adam 改了什么?
A 初期参数随机、梯度大且方向不稳,Adam 的二阶矩估计还不准,直接用峰值 lr 容易发散。AdamW 把 weight decay 从梯度中解耦、直接作用于参数,修正了 L2 正则与自适应缩放互相干扰的问题。(09 )
Part 2 · Transformer
Q: Attention 相比 RNN 的核心优势和代价?
A 任意两 token 路径长度 O(1)、训练完全并行;代价是计算和显存随序列长度平方增长(每层 O(T²·d)),长上下文优化基本都在跟 T² 较劲。(10 )
Q: Causal mask 为什么用 −inf 而不是在 softmax 后乘 0?
A mask 要加在 softmax 之前:未来位置分数设为 −inf,exp 后恰好为 0,其余权重自动归一化到 1。若 softmax 之后再把未来位置乘 0,剩余权重之和小于 1;若 softmax 前填 0 而不是 −inf,exp(0)=1 照样分到权重。(10 11 )
Q: 为什么要除以 √d_k?
A q、k 各分量方差为 1 时点积方差为 d_k(d_k=64 时标准差 8),不缩放 softmax 会饱和成 one-hot、梯度消失。这是数值稳定性设计,不是「归一化」(softmax 已归一化)。(11 )
Q: 序列长度从 4K 到 32K,单层 attention 分数矩阵变几倍?
A 长度 ×8,元素数 ×64(平方关系)。这正是 FlashAttention 不物化该矩阵的动机。(10 40 )
Q: 为什么 decode 阶段 attention 不需要 causal mask?
A 每步只有 1 个新 Query,它本来就在序列最后,缓存里的 K/V 全是「过去」。Q 形状 (B,1,d)、分数 (B,1,T),这也是 decode 访存密集的原因。(11 )
Q: 把 d_model=4096 从 32 头改成 64 头,参数量和 FLOPs 变吗?
A 都不变:head_dim 从 128 变 64,W_Q/K/V/O 仍是 4d²,QKᵀ 总 FLOPs = T² × d_model。head 数只改切分方式。(12 )
Q: 为什么 Tensor Parallel 最自然的切法是按 head 分卡?
A head 之间在 attention 计算时互不通信、天然并行;每卡算自己的 head,W_O 按行切,最后一次 all-reduce 汇总。所以 n_head(GQA 下 n_kv_head)通常要能被 TP size 整除。(12 )
Q: 为什么 Attention 需要位置编码?GPT-2 的方案有什么硬伤?
A Attention 是置换不变的,只看内容不看顺序。GPT-2 用 1024 行的可学习绝对位置表:编码绝对而非相对位置,且第 1025 个位置直接越界,完全无法外推。(13 )
Q: RoPE 为什么只旋转 Q/K 不旋转 V?KV Cache 里存的是什么?
A 位置只需影响「看谁」(Q·K 打分),相对位置性质靠 q、k 的旋转互相抵消得到,V 不参与点积,旋转无意义。缓存里存的是已经旋转过的 K,decode 只需旋转新 token 的 Q 和 K。(13 )
Q: Pre-Norm 相比 Post-Norm 好在哪?代价是什么?
A residual 主路径保持纯加法,梯度直达,深层训练稳定、对 warmup 不那么敏感(实践中仍用 warmup);代价是 residual stream 尺度随层数增长,必须在最后加一个 final norm。(14 )
Q: 为什么 LLM 用 LayerNorm/RMSNorm 而不是 BatchNorm?RMSNorm 省了什么?
A BN 依赖 batch 内其他样本,推理 decode 时行为与训练不一致;LN/RMSNorm 对每个 token 独立计算。RMSNorm 去掉减均值和 β,只按均方根缩放,参数减半(d=4096 时 4096 vs 8192)且更便宜。(14 )
Q: SwiGLU 的中间维为什么不是 4d?Llama-2-7B 的 11008 怎么来的?
A SwiGLU 有 gate/up/down 三个矩阵,若仍用 4d 参数是 12d²;为与普通 FFN 的 8d² 持平缩到 8d/3:8/3 × 4096 ≈ 10922(取整数部分),再向上取整到 256 的倍数得 11008。Llama-3-8B 再乘 ffn_dim_multiplier=1.3(≈14199),向上取整到 1024 的倍数得 14336(3.5×d),即故意加宽。(15 )
Q: 为什么 FFN 是 TP 和 MoE 的天然切入点?
A FFN 对每个 token 独立计算(position-wise):TP 时第一个矩阵按列切、第二个按行切,中间的逐元素激活无需通信,整个 FFN 只要一次 all-reduce。也正因 token 之间互不依赖,可以把不同 token 送进不同的 FFN(Expert),这就是 MoE。(15 )
Q: 用 12·L·d² 估算 GPT-2 small 为什么是 124M?
A 12 × 12 × 768² ≈ 85M(非 embedding),加上 token embedding 50257 × 768 ≈ 38.6M(与 lm_head 共享)和位置表,约 124M;embedding 占约 31%。(16 )
Q: top-p 比 top-k 好在哪?min-p 又好在哪?
A top-k 候选数固定,分布尖时放进垃圾、平时又太少;top-p 保留累积概率 ≥ p 的最小集合,随分布自适应。min-p 阈值 = min_p × 最大概率,随模型自信度缩放,高 temperature 下更稳。(17 )
Q: 为什么 temperature=0 的服务端结果也可能不一致?
A 算法上是 greedy,但 batch 组成变化会改变浮点累加顺序,logits 出现微小差异,argmax 可能翻转(batch invariance 问题)。(17 )
Q: repetition_penalty 设得很大有什么副作用?
A 它惩罚所有出现过的 token,包括代码和 JSON 里必需的重复符号,会破坏格式;通常只在 1.0–1.2 微调。(17 )
Part 3 · 现代 LLM 架构
Q: 671B 总参 / 37B 激活的 MoE,算训练 FLOPs 用哪个 N?为什么?
A 用激活参数 37B:每个 token 只经过被选中的 Expert,未选中的不产生计算。(18 21 )
Q: 同样算力,Meta 为什么不按 Chinchilla 比例训一个 ~77B 而是训 8B?
A 8B 推理便宜近 10 倍、可单卡部署;多花的训练算力是一次性成本,推理成本是持续成本。(18 )
Q: Scaling law 能预测一切能力吗?
A 对 pretraining loss 很准;对具体下游能力(尤其所谓涌现能力)误差大得多。Llama 3 用两步拟合 FLOPs → NLL → accuracy 来改善。(18 )
Q: GQA 省了 attention 的计算量吗?为什么它对 decode 帮助大、对 prefill 帮助小?
A 主要省 KV Cache 显存和带宽,QKᵀ 仍按 Q 头数算。prefill 是 compute-bound,花在矩阵乘上;decode 每步读全部 KV,是 memory-bound,KV 变小直接少读字节。(19 )
Q: 为什么 KV head 数常取 8?MQA 在 TP 下有什么问题?
A 8 个 KV head 正好与单机 8 卡 TP 对齐,每卡 1 个。MQA 只有 1 个 KV head,TP=8 时每卡都要存完整副本,省下的显存又被复制回来。(19 )
Q: 一张 80GB 卡跑 Llama-3-8B,剩 60GB 全给 KV,能放多少 token?
A 60GiB ÷ 128KiB ≈ 49 万 token,约 60 条 8K 请求(粗算)。(19 )
Q: MLA 省了显存也省了计算吗?
A 没省计算:decode 时每头要在 576 维上做点积,attention FLOPs 反而比 GQA 高。它是用计算换带宽,恰好适合 memory-bound 的 decode;prefill 则常解压成普通 MHA 计算。(20 )
Q: 为什么 DeepSeek-V3 的 config 里 num_key_value_heads: 128 不能用来算 KV Cache?
A 那是为兼容 HF 通用接口;MLA 实际缓存 kv_lora_rank + qk_rope_head_dim = 576 维/层/token,与头数无关:576 × 61 层 × 2B ≈ 68.6KB。(20 26 )
Q: 为什么 MLA 模型常用 DP-Attention 而不是 TP?
A latent KV 被所有头共享,按头做 TP 时每张卡都得存完整 c_KV,KV 被复制 TP 份;DP-Attention 让 attention 按请求切、每卡只存自己请求的 KV,MoE 部分走 EP。(20 44 )
Q: Mixtral 8x7B 为什么是 47B 而不是 56B?每 token 算多少?
A 只有 FFN 被复制成 8 份,attention / embedding 共享,总参约 47B;Top-2 路由,每 token 激活约 13B。(21 )
Q: EP=8 时每层 MoE forward 需要几次 all-to-all?
A 两次:dispatch(token 发到 Expert 所在卡)和 combine(结果发回)。(21 )
Q: MoE 推理一定比同总参的 dense 快吗?
A 小 batch 时不会快多少。decode 受带宽限制,而一批里不同 token 会激活不同 Expert,要读的权重接近全部 Expert,每个 Expert 只分到零星 token、变成 GEMV;优势在大 batch 吞吐场景,所以 DeepSeek 用大规模 EP 部署。(21 )
Q: 传统 aux loss 有什么问题?DeepSeek-V3 怎么做均衡?
A 系数大了均衡梯度干扰主任务、小了压不住。V3 给每个 Expert 一个只影响 top-k 选择、不进加权的 bias,按负载在线加减,另留系数 0.0001 的序列级 loss 兜底,且不丢 token。(22 )
Q: 1024 token、64 Expert、top-2、capacity factor 1.0,每个 Expert 容量多少?溢出的 token 怎样?
A 1.0 × 1024 × 2 / 64 = 32 个 token。溢出 token 跳过这层 MoE,只经 residual 进入下一层,损失了该层计算。(22 )
Q: Fine-grained Expert 和 Shared Expert 各解决什么?
A 细粒度:把 Expert 切小、激活数同比增加,计算不变但组合数暴增(8 选 2 = 28 种,32 选 8 ≈ 1050 万种),更专精;Shared:每个 token 必经,学通用知识,减少路由 Expert 之间的冗余。Qwen3 MoE 去掉了 Shared,说明它不是必选。(22 )
Q: RoPE 外推失败的根源是什么?PI 与 YaRN 的差别?
A 低频维度在训练长度内没转满一圈,推理时出现没见过的角度。PI 把所有位置整体压缩(高频也被压,相邻 token 难区分);YaRN 高频不动、只插值低频、中间过渡,再做温度修正(q、k 各乘 0.1·ln s + 1,logits 相当于乘它的平方),效果最好。(23 )
Q: 纯滑动窗口做无限流式生成为什么会崩?怎么修?
A 开头几个 token 是 attention sink,承接「无处可去」的注意力(softmax 必须归一化);挤出 cache 后注意力分布剧变。修复:永远保留开头 4 个 token + 最近窗口;gpt-oss 直接给每头加可学习 sink logit。(23 )
Q: max_position_embeddings 就是模型真实可用的上下文吗?
A 不是,它只是上限配置;要结合 rope_scaling 和官方说明,如 Qwen3 原生 32K、开 YaRN 才到 128K。(23 )
Q: Linear attention / SSM 相比 softmax attention 的根本取舍?
A 用定长 state 取代随长度增长的 KV Cache,推理每步 O(1);代价是有损压缩,精确召回(needle-in-a-haystack、复制)变差。所以主流是 hybrid:大部分线性层 + 少数 full attention 层。(24 )
Q: 为什么 hybrid 模型在短对话场景显存优势不明显?还给推理引擎带来什么麻烦?
A 线性层 state 是固定开销(Qwen3-Next 约 36MiB/序列),短序列时可能比省下的 KV 还大。麻烦:要同时管理 paged KV 和定长 state;prefix caching 只能在特定位置做 state 快照;投机解码回滚需要保存中间 state。(24 )
Q: DeepSeek-V3 的 MTP 在训练和推理中各有什么用?
A 训练:每个位置多一个监督目标,信号更密(λ 前 10T 为 0.3、后 4.8T 为 0.1)。推理:可直接丢弃,也可当 draft 做投机解码,第二 token 接受率 85%–90%,约 1.8x TPS。(25 42 )
Q: 为什么 Kimi K2 用 QK-Clip 而不是 QK-Norm?
A K2 用 MLA,推理做 weight absorption,Key 从未被完整算出,没地方插 norm;于是监控每头最大 logit,超阈值就直接缩放该头的 W_q/W_k。(25 )
Q: 看到一个新模型的 config,第一眼算哪些东西?
A 层数、Q/KV 头数、head_dim → KV Cache/token;Expert 数、top-k、moe_intermediate_size → 总参与激活参;torch_dtype / quantization_config → 权重显存;rope_scaling → 真实上下文;再判断并行方式(dense ≤ 70B 单机 TP,MoE 大模型 EP + DP-Attention)与所需 kernel。(26 )
Q: Llama 3.1 405B 每 token KV 多大?128K 一条请求呢?
A 2 × 126 × 8 × 128 × 2B ≈ 504KiB;× 131,072 ≈ 63GiB。(26 )
Part 4 · 训练流水线
Q: 预训练数据 pipeline 有哪几步?
A 抽正文 → 语言识别 → 启发式规则过滤 → 去重(exact + MinHash/LSH)→ 模型打分过滤 → 按领域配比 → tokenize 成二进制 shard。本质是超大规模 ETL。(27 )
Q: 为什么去重不是越狠越好?
A FineWeb 发现跨 snapshot 全局去重后,老 snapshot 被删近 90%,留下的反而更差——被广泛转载的往往是好内容;最后改为 snapshot 内去重。(27 )
Q: 用 LLM 直接给 15T token 网页打分可行吗?业界怎么做?
A 太贵。用大模型只标几十万样本,训一个小分类器(fastText / embedding + 线性头),再对全量推理。(27 )
Q: BF16 比 FP16 更精确吗?为什么它成了训练标准?
A 恰好相反,BF16 尾数更少、精度更低;它赢在范围:8 位指数与 FP32 相同,训练基本不溢出、不需要 loss scaling。精度不足的问题靠 FP32 master weights 解决。(28 )
Q: 为什么 master weights 必须留在 FP32?
A BF16 只有 7 位尾数,lr × grad 这种小更新加到大权重上会被舍入吞掉;在 FP32 上累积更新,再转成 BF16 做计算。(28 )
Q: FP8 训练的关键难点和 DeepSeek-V3 的做法?
A FP8 只有 2–3 位尾数,整张量共用一个 scale 易溢出或丢精度;V3 用细粒度 scaling:激活 1×128、权重 128×128 分块,并定期把累加提升到 FP32。(28 )
Q: 为什么卡越多 ring all-reduce 并不会线性变慢?
A ring all-reduce = reduce-scatter + all-gather,每卡收发约 2 × 数据量,与卡数 N 基本无关;只有延迟项随 N 增长,大消息下带宽项主导。(29 )
Q: 为什么 ZeRO-1/2 的通信量和 DDP 一样?ZeRO-3 多了什么?
A DDP 的 all-reduce 本来就是 reduce-scatter + all-gather;ZeRO-2 只是改成先 reduce-scatter 梯度、各自更新 1/N 参数、再 all-gather 参数,总字节相同。ZeRO-3 在 forward/backward 还要 all-gather 参数,约 1.5 倍。(29 )
Q: 70B 模型、64 卡,ZeRO-3 与 ZeRO-1 每卡模型状态各多少?
A ZeRO-3:16 × 70e9 / 64 ≈ 17.5GB。ZeRO-1:权重+梯度 4 × 70 = 280GB 不切 + 12 × 70 / 64 ≈ 13GB,约 293GB,放不下。(29 )
Q: ZeRO-3 和 Tensor Parallel 都「切参数」,区别是?
A ZeRO-3 只切存储,计算前 all-gather 回完整层,每卡做完整计算;TP 真正把矩阵乘切开,每卡只算部分,activation 也被切开。ZeRO 也不解决 activation 和单层过大问题。(29 30 )
Q: PP=16、64 个 micro-batch,bubble 占比约多少?1F1B 减少了 bubble 吗?
A (p−1)/(m+p−1) = 15/79 ≈ 19%。非 interleaved 的 1F1B bubble 与 GPipe 相同,它省的是 activation 显存(在飞 micro-batch ≤ p);interleaved 和 DualPipe 才进一步压 bubble。(30 )
Q: 4D 并行的组合原则?DeepSeek-V3 怎么配?
A 通信越密越往里放:TP 在节点内(NVLink),CP 次之,PP 可跨节点,DP 最外层。V3 在 2048 张 H800 上不用 TP:PP16(DualPipe)× EP64(跨 8 节点)× ZeRO-1 DP,并限制 token 最多去 4 个节点。(30 )
Q: Sequence Parallel 和 Context Parallel 是一回事吗?
A 不是。Megatron-SP 只切 TP 区域外的 Norm/Dropout,依附于 TP;CP 切整条序列(含 attention,需 Ring Attention / all-gather KV / Ulysses),是独立并行维度,用于超长上下文训练。(30 )
Q: SFT 的 loss 为什么只算 assistant 部分?结束 token 要不要算?
A 要学「如何回答」而不是如何提问或复述 system prompt,其余 label 设 −100。结束 token(<|eot_id|>)必须算,否则模型学不会停止。(31 )
Q: 模型上线后一直不停止生成,最可能的原因?
A ① 推理服务没把 <|eot_id|> / <|im_end|> 配成 stop token(只配了 <|end_of_text|>);② SFT 时结束 token 被 mask 没学会,或推理端 chat template 与训练不一致。(31 )
Q: 为什么 <|im_end|> 必须是一个独立 special token?
A 生成时要能用单个 token id 判断停止;并且防注入:用户文本里的同名字符串会被切成普通 token,不会被当成真正的轮次边界。(31 )
Q: PPO 版 RLHF 的 4 个模型各干什么?70B 规模仅权重多少显存?
A actor(被训练的策略)、critic(估 value 算 advantage,也训练)、reward model(打分,冻结)、reference(算 KL,冻结)。4 × 70B × 2B = 560GB;actor 和 critic 还要梯度与 optimizer state(按 16 字节/参数共约 2.24TB),加上 RM 与 reference 约 2.5TB,还没算 activation。(32 )
Q: DPO 是怎么「去掉」RM 和在线采样的?代价?
A KL 约束下最优策略有闭式解,reward 可写成 β·log(π/π_ref),代入 Bradley-Terry 得到偏好对上的分类 loss,只需 policy + reference。代价是 offline,无法探索模型自己的新回答,分布漂移后可能不如 online 方法。(32 )
Q: 为什么 RLHF 不能长跑,RLVR 可以?
A RLHF 的奖励是神经网络 RM,分布外打分不可靠,会被 reward hacking;RLVR 的奖励来自规则验证(答案比对、单元测试),很难被钻空子,可以像 AlphaGo 一样一直跑。(32 33 )
Q: GRPO 相比 PPO 省了什么?一组 8 个回答全对时梯度是多少?
A 省掉与 actor 同规模的 critic(参数、梯度、优化器状态),也不需要 RM;代价是每题采多个回答、整条回答共享一个 advantage。全对时 advantage 全为 0,除 KL 外无贡献——题太简单,要按难度筛数据。(33 )
Q: RL infra 为什么难?(至少说三点)
A rollout(推理)占 RL step 大头且长度长尾;训练(FSDP/Megatron)与推理(vLLM/SGLang TP/EP)两套并行布局每步要同步权重;colocate 要 offload/onload,disaggregated 要跨机传权重;还有代码沙箱、答案判定等奖励服务。对策含 partial rollout、异步 RL。(33 )
Q: LoRA 的 B 为什么零初始化?两个都零会怎样?
A B=0 保证起始 ΔW=0、不破坏原模型;A 随机保证梯度能流动。都为 0 时 A 和 B 的梯度都为 0,永远学不动。(34 )
Q: 8192×8192 矩阵挂 r=8 的 LoRA,可训练参数占多少?
A 2 × 8192 × 8 = 131,072,约为原矩阵(约 67M)的 0.2%。(34 )
Q: QLoRA 是在 4 bit 上做计算吗?70B 的 base 占多少显存?
A 不是,NF4 只是存储格式,前向逐块反量化成 BF16 计算,LoRA 参数本身是 BF16/FP32。70B × 0.5B ≈ 35GB,加量化常数、LoRA 状态和 activation,单张 80GB 可行。(34 )
Part 5 · 推理与 AI Infra
Q: H100 的 ridge point 约多少?怎么用它判断 kernel 瓶颈?
A 989 TFLOPS / 3.35 TB/s ≈ 295 FLOP/byte(A100 约 156)。kernel 算术强度低于它就是 memory-bound,优化方向是少搬数据而不是少算。(35 )
Q: 为什么 batch=1 的 GEMV 算术强度约为 1?
A 每个 BF16 权重 2 bytes,读进来只和输入向量的一个元素做一次乘加(2 FLOP),2/2 = 1。(35 )
Q: nvidia-smi 显示 GPU-Util 100%,说明算力用满了吗?
A 不说明。它只表示采样时刻有 kernel 在跑,memory-bound kernel 也显示 100%,Tensor Core 可能只用了 1%。要用 ncu 看,或算 MFU/MBU。(35 )
Q: 「H100 算力是 A100 三倍,推理就快三倍」对吗?H200 为什么是「为推理加料」?
A 只对 compute-bound 部分(prefill、训练)接近成立;decode 受带宽限制,提速约 3.35/2.0 ≈ 1.7 倍。H200 算力同 H100,但 HBM 141GB、4.8 TB/s,正好补 decode 的容量和带宽。(35 )
Q: 推理框架为什么要用 CUDA Graph?
A decode 每步几百个小 kernel,每次 launch 几微秒的 CPU 开销在小模型上会让 GPU 空等;预先 capture、每步 replay 就省掉这些 launch。vLLM 用 piecewise CUDA Graph,把形状多变的 attention 留在图外。(35 45 )
Q: Llama-3-8B BF16 在 H100 上单请求 decode 上限多少?怎么算?
A 每步至少读 16GB 权重,16GB / 3.35 TB/s ≈ 4.8ms,上限约 210 tokens/s(A100 约 125);实际 MBU 不到 100%,会更低。(36 )
Q: 权重量化到 FP8 / INT4,单请求 decode 理论上能快几倍?为什么与算力无关?
A 单请求 decode 速度 ≈ 带宽 / 每步读取字节:BF16 → FP8/INT8 字节减半,上限约 2x;→ INT4 只剩 1/4,理论上接近 4x(反量化等开销让实测打折扣)。Tensor Core 本来就闲着,瓶颈只在搬字节。(36 43 )
Q: Llama-3-8B decode 从 batch 1 提到 32,吞吐变几倍?
A 短上下文接近 32 倍(权重读取共享);上下文越长 KV 读取越多:2048 上下文时 KV 约 8GiB,单步约 7.4ms,约 21 倍。(36 )
Q: 70B BF16 在 8×H100 TP=8,batch=1 decode 上限约多少?
A 权重约 140GB,8 卡总带宽约 26.8 TB/s,单步约 5.2ms,上限约 190 tokens/s,还要扣掉 TP all-reduce 开销。(36 )
Q: 为什么只缓存 K、V 而不缓存 Q?
A 过去 token 的 Query 以后不会再用;后续 token 只会读历史 token 的 K、V。(37 )
Q: 为什么说 KV Cache「省计算、费带宽」?
A 它避免每步重算全部历史 token 的 K/V(省计算),但每个 decode step 都要从 HBM 读全部历史 KV 做 attention,上下文越长读得越多,也占大量显存。(37 )
Q: Llama-3-70B 单个 128K 请求的 KV 多大?Llama-2-7B 和 Llama-3-8B 每 token KV 为何差 4 倍?
A 320KB × 131072 ≈ 40GB,半张 H100。后者差在 GQA:32 个 Q 头共享 8 个 KV 头(512KB → 128KB)。(37 )
Q: vLLM 并发上不去,第一步看什么?
A 启动日志的 GPU KV cache size: N tokens 和 Maximum concurrency for X tokens per request;以及 gpu_memory_utilization(默认 0.92,决定 KV 总预算)是否设低了、max_model_len 是否过大、num_preemptions 是否持续增长。手段:KV 量化、减小 max_model_len / max-num-seqs、加卡。(37 39 )
Q: PagedAttention 让 attention 算得更快了吗?减小了 KV Cache 吗?
A 都没有。论文里原始 PagedAttention kernel 因为查 block table 等开销,比 FasterTransformer 慢 20–26%(该 kernel 现已从 vLLM 移除,改用 FlashAttention / FlashInfer 的 paged 接口);每 token KV 字节不变(那是 GQA/MLA/量化的事)。它减少的是浪费,收益来自更大的 batch。(38 )
Q: block size=16,一个 100 token 的请求占几个 block、浪费几个 slot?block 为什么不越小越好?
A 7 个 block、112 个 slot,浪费 12 个。太小会让 block table 变长、访存更碎、kernel 效率下降;太大内部碎片多、难共享。16 是实验结果。(38 )
Q: beam search 为什么比 parallel sampling 从 KV 共享中省得多?
A parallel sampling 只共享 prompt(省 6.1–9.8%);beam search 各候选还共享大量已生成前缀,随分叉淘汰动态共享(省 37.6–55.2%)。(38 )
Q: static batching 下 8 个请求,7 个输出 50 token、1 个输出 1000 token,slot 利用率?
A 有效 step = 7×50 + 1000 = 1350,占用 8 × 1000 = 8000,约 17%。continuous batching 就是为了消灭这种空转。(39 )
Q: continuous batching 和 dynamic batching 的区别?
A dynamic batching 仍是请求级:攒一批后跑到结束;continuous batching 在每个 token step 重组 batch,请求随时进出。(39 )
Q: 为什么线性层能跨请求拼 batch,而 attention 要区分请求?
A 线性层对每个 token 独立计算,不关心 token 属于哪个请求(Orca 的 selective batching);attention 要让 token 只看同一请求内的 token,且每个请求 KV 长度不同,需要 varlen / paged kernel。(39 )
Q: chunked prefill 的 token budget 从 2048 调到 512,TTFT 和 TPOT 怎么变?
A 长 prompt 被切成更多 chunk,TTFT 变长;每步工作量更小,decode 的 TPOT 更低更平稳。总计算量基本不变(只是多读几次已算好的 KV)。(39 )
Q: 显存不够时 swap 和 recompute 怎么选?vLLM V1 选了什么?
A vLLM 论文实验:block 小时 recompute 占优(swap 传输太碎),block 大时 swap 占优,16–64 之间两者相近。V1 只做 recompute,理由是逻辑简单、不占 CPU 内存和 PCIe、还能命中 prefix cache,而不是它一定更便宜;默认后到的请求先被抢占。(39 )
Q: online softmax 遇到更大的最大值时为什么要乘 exp(m_old − m_new)?
A 之前的项按 exp(s − m_old) 累积,换成新基准 m_new 时每项都要乘这个因子,所有块的结果才在同一基准上、可以直接相加;修正是精确的。(40 )
Q: FlashAttention backward 为什么宁可重算 P 也不保存?
A 保存 P 要 O(N²) 显存且要从 HBM 读回;从 SRAM 中的 Q、K tile 重算虽多了 FLOPs,但这里是 IO-bound,重算反而更快,还省显存。(40 )
Q: 有了 FlashAttention 还需要 PagedAttention 吗?
A 需要,二者解决不同问题:FlashAttention 优化单次 attention 计算的 IO,PagedAttention 管理跨 step 的 KV 存储;推理中 FA/FlashInfer 直接提供 paged KV 接口,组合使用。(40 )
Q: Flash-Decoding 解决什么问题?
A decode 时 query 长度为 1,按 Q 分块并行度不够;改为沿 KV 长度切给多个 thread block 各算一段,再用 online softmax 的 (m, l) 规则合并。(40 )
Q: 两个请求共享前 1000 个 token,vLLM APC(block=16)能复用多少?为什么哈希要含父 block?
A 62 个完整 block = 992 个 token,剩余 8 个所在的 block 不能复用。同样 16 个 token 出现在不同前缀之后 KV 不同,含父哈希后「哈希相等 ⇔ 整段前缀相等」。(41 )
Q: RadixAttention 为什么淘汰叶子而不是任意节点?
A 靠近根的是被更多请求共享的公共前缀,价值更高;子节点的 KV 依赖父节点,淘汰中间节点会让下面整棵子树不可用。(41 )
Q: 命中 prefix cache 后 decode 会变快吗?设计 prompt 要注意什么?
A 不会,它只省 prefill(降 TTFT、省算力),decode 每步仍读全部 KV。prompt 把固定内容放前、可变内容放后,不要在开头放时间戳或随机 ID。(41 )
Q: 多副本部署时 prefix caching 命中率为什么会掉?
A 每个实例只有自己的缓存,负载均衡随机分发会把同前缀请求打散;需要 cache-aware routing(sgl-router〔现名 sgl-model-gateway〕、llm-d),再往下是 LMCache / Mooncake 这类多级共享 KV 存储。(41 )
Q: α=0.8、γ=4 时每轮期望产出多少 token?为什么说 α 比 γ 重要?
A (1 − α^{γ+1})/(1 − α) ≈ 3.36。被拒位置之后全部作废,α^γ 衰减很快,γ 再大也很快饱和;α=0.6 时只有约 2.3。(42 )
Q: draft 给某 token q=0.6,target 给 p=0.3,接受概率?被拒后从哪采?
A min(1, 0.3/0.6) = 0.5;被拒后从 max(0, p−q) 归一化的残差分布采样,该 token 在残差里概率为 0。(42 )
Q: EAGLE 和普通 draft model 的区别?n-gram 什么时候好用?
A 普通 draft 是同词表小模型,要多跑一个模型和一份 KV,α 有限;EAGLE 用约 1 层的小网络,吃 target 的 feature(LM head 之前那层 hidden)+ 已采样 token 的 embedding 起草,复用 target 的 LM head,α 高、极便宜,但要为每个 target 训练。n-gram / prompt lookup 零训练,只在 RAG、代码编辑、摘要等「复读输入」的场景有效。(42 )
Q: 为什么 SmoothQuant 要把难度从激活迁移到权重?
A 激活的 outlier 集中在固定的少数 channel(列),可达上百倍:按 token(行)量化时每一行都被这几列拉大 scale、其余值精度全丢;按 channel 量化激活又和 GEMM 的计算方式不兼容。于是按 channel 把激活除以 s、权重乘以 s(结果不变),把难度挪到本来就平滑、可按 channel 量化的权重上,两边都能用 INT8。(43 )
Q: AWQ 如何保护重要权重却不用混合精度?
A 约 1% 的 salient 通道(对应大激活)决定质量;量化前把它们乘 scale 放大、激活侧除回(数学等价),相对量化误差变小,无需反向传播。(43 )
Q: 32B 模型 W4(group=128,FP16 scale)约多大?Llama-3-70B 开 FP8 KV 每 token 多少?
A 32B × 0.5B = 16GB,加 scale(约 +3%)约 16.5GB。70B FP8 KV:80 × 8 × 128 × 2 × 1B ≈ 160KB,是 BF16 的一半。(43 )
Q: MXFP4 和 NVFP4 的区别?按硬件怎么选量化?
A MXFP4 每 32 元素一个 E8M0(2 的幂)scale,约 4.25 bit;NVFP4 每 16 元素一个 FP8 E4M3 scale 加 per-tensor scale,约 4.5 bit,精度通常更好。A100 无 FP8 → INT8/W4A16;H100/H200 → FP8;B200/GB200 → 原生 FP4。(43 )
Q: 为什么 KV Cache 量化常比权重量化更划算?
A 长上下文高并发时 KV 远大于权重,FP8 KV 让同样显存放下两倍 token,直接提高并发和吞吐;但 KV 误差会沿注意力累积,所以主流是 FP8,INT4 / FP4 KV 还在探索中。(43 )
Q: 为什么 DeepSeek 的 decode 单元(18 节点 EP144)比 prefill 单元(4 节点 EP32)大得多?
A decode 是 memory-bound,需要极大并发 batch 才能把 Expert GEMM 做「胖」;摊到 144 卡后每卡只放约 2 个 routed expert,更多显存留给 KV 和更大 batch。prefill token 多、已 compute-bound,小单元就够。(44 )
Q: DeepEP 为什么要两套 kernel?
A normal kernel 走 NVLink + RDMA 转发、追求高吞吐,用于 prefill;low-latency kernel 纯 RDMA、兼容 CUDA Graph,用于延迟敏感的 decode。(44 )
Q: PD 分离后,TTFT 和 TPOT 分别由什么决定?
A TTFT 由 prefill 池(排队 + prefill)加 KV 传输决定;TPOT 由 decode 池的 batch 与负载决定,且不再被新请求的 prefill 打断。(44 )
Q: vLLM V1 为什么把 EngineCore 放到独立进程?
A Python 有 GIL,tokenize / detokenize / HTTP 处理会抢调度循环的 CPU,导致 GPU 空等;拆进程(ZMQ + msgpack)后三者真正并行。官方称 V1 吞吐最高 1.7x。(45 )
Q: vLLM V1 调度器怎么统一 prefill、decode、chunked prefill、prefix cache、spec decode?
A 不区分阶段,每个请求只有 num_computed_tokens 与 num_tokens,每步在 token budget 内给请求分 token 让前者追上后者:新请求预算不够就少给(chunked prefill),decode 每步 1 个(spec 时 1+k),prefix 命中则直接从命中位置起步。(45 )
Q: max_num_batched_tokens=2048、100 个 decode 请求、新来 5000 token prompt,本步它算多少?
A 先给 100 个 decode 各 1 token,剩 1948 给新请求做 prefill,余下部分下一步继续。(45 )
Q: SGLang 与 vLLM V1 的进程结构有什么不同?
A 思想相同(CPU 杂活移出 GPU 主循环)。SGLang:TokenizerManager、Scheduler、DetokenizerManager 分进程,Scheduler 与 TpModelWorker 同进程(TP 时每 rank 一份,rank 0 收发);vLLM:detokenize 在 API Server 里,EngineCore 与 Worker 分进程。(46 )
Q: SGLang 的 overlap scheduler 怎么解决「下一步需要上一步采样结果」的依赖?
A CPU 调度 N+1 时还不知道 N 采出的 token,就先放一个 future token 占位(只记下「去 GPU 上 future buffer 第几格取」);GPU 按顺序执行到 N+1 时,N 已把真值写进那格,直接读取,CPU 不用等 GPU。遇到必须同步的情况(如某些 grammar / spec 场景)对该 batch 关闭 overlap。(46 )
Q: 结构化输出的 jump-forward 什么时候能省掉 forward?xgrammar 又快在哪?
A FSM 当前状态只有唯一合法后续字符串(JSON 固定 key 与标点)时,直接整段 append。xgrammar 把大部分 token 的合法性预计算成 bitmask,运行时只处理少量上下文相关 token,并与 GPU 计算重叠。(46 )
Q: TTFT=400ms、输出 201 token、E2E=10.4s,TPOT 多少?
A (10.4 − 0.4)/(201 − 1) = 50ms,约 20 tokens/s。(47 )
Q: TPOT 和 ITL 有什么区别?为什么要看 P99 ITL?
A TPOT 按请求平均,ITL 按每个相邻 token 间隔统计;P99 ITL 能暴露长 prefill 插队造成的卡顿,这是 chunked prefill 和 PD 分离的动机。(47 )
Q: 平均 E2E 8 秒、要撑 5 req/s,系统里平均有多少并发?
A Little's Law:5 × 8 = 40 个在跑请求,KV Cache 要按 40 个请求的上下文预留。(47 )
Q: 框架 A 最大吞吐比 B 高 20%,但在你的 SLO 下 goodput 更低,选谁?
A 选 B。最大吞吐通常出现在延迟已超标的高并发区间,对业务无意义;应在 SLO 处比 goodput 或 Pareto 曲线,并用自己的真实流量分布压测。(47 )
Q: vLLM、SGLang、TensorRT-LLM 怎么选?
A 没有脱离负载的答案。一般:模型 / 硬件杂、要最广生态 → vLLM;前缀复用重(多轮、agent、RAG)、DeepSeek 类大规模 EP/PD、RL rollout → 重点测 SGLang;全栈 NVIDIA、追求 Blackwell 极限 → 评估 TensorRT-LLM;本地单用户 → llama.cpp / Ollama。(47 )
🆕 CS336 补充课(18a / 34a / 34b / 40a)
Q: IsoFLOP 法怎么拟合 scaling law?
A 选几个小算力预算,每个预算扫一排 N(D = C/6N)训到底,对 loss–log N 拟合抛物线取谷底得到 N_opt;再在 log-log 上拟合 N_opt ∝ C^a 外推到目标预算。关键是每个小实验都要训到位、超参在各自规模上调到最优。(18a )
Q: 为什么标准参数化下不能把小模型的 lr 直接用到大模型?μP 怎么解决?
A Adam 每步对每个权重的改动约等于 lr,宽度为 d 的矩阵乘把 d 个改动累加到输出,模型越宽扰动越大,最优 lr 随宽度下降。μP 按宽度倍数 m 缩放:隐藏层 lr × 1/m、输出 logits × 1/m、attention 用 1/d_head,使最优 lr 不随宽度漂移,可以从约 40M 的代理模型迁移到 6.7B。(18a )
Q: WSD 调度是什么?为什么适合做 scaling 实验?
A Warmup → 恒定 lr 的 Stable 段 → 最后约 10–20% 快速 Decay。从 stable 段任意 checkpoint 分叉做 decay 就得到一个「训到位」的结果,一次主干就能产出多个数据量的数据点;decay 完的 loss 与同长度 cosine 基本持平。(18a )
Q: Perplexity 和 bits-per-byte 是什么关系?为什么要换算?
A Perplexity = exp(每 token 平均 cross-entropy),按 token 计,不同 tokenizer 不可比;bits-per-byte = 总 NLL ÷ ln2 ÷ 原文字节数,与 tokenizer 无关。英文约 4 字节/token、loss 2.0 nats 时 ≈ 0.72 bits/byte。(34a )
Q: AIME 只有 30 题,73.3 vs 70.0 的差距有意义吗?
A 没有。p≈0.5 时 SE = √(0.25/30) ≈ 9 分,95% 区间约 ±18 分;两个分数只差 1 道题。小题集要看多次采样均值(avg@k)并跨多个 benchmark 验证。(34a )
Q: pass@k 怎么正确估计?c=3、n=20 时 pass@1 和 pass@10 各多少?
A 每题采 n ≥ k 个、数出 c 个通过,用无偏估计 1 − C(n−c,k)/C(n,k)。c=3、n=20:pass@1 = 0.15,pass@10 ≈ 0.89,所以报告必须写明 k。(34a )
Q: LLM-as-a-Judge 有哪三种典型偏差?
A 位置偏差(偏好先出现的答案,要交换顺序评两次)、长度偏差(偏好更长的回答,用 length-controlled 指标校正)、自我偏好(偏好同家族模型的回答)。(34a )
Q: LLaVA 式 VLM 由哪三部分组成?怎么训练?
A Vision Encoder(CLIP / SigLIP ViT)+ Projector(2 层 MLP)+ LLM。Stage 1 冻结 ViT 和 LLM,只训 Projector 做图文对齐;Stage 2 解冻 LLM,用图文多轮问答数据做视觉指令微调。(34b )
Q: Qwen2-VL 输入一张 1008×1008 的截图,多少图像 token?在 Qwen2.5-VL-7B 上占多少 KV Cache?
A 每 token 28×28 像素 → 36² = 1296 个 token;每 token KV = 2×28×4×128×2 B = 56 KiB,合计约 71 MiB。(34b )
Q: 为什么 VLM 的 prefix caching 要对图像内容求 hash?
A 不同图片在 prompt 里都是同一个占位 token id,只比 token id 会把不同图片当成相同前缀而错误复用 KV;必须把图像内容的 hash 纳入 block hash。(34b )
Q: [8192, 4096] BF16 的 RMSNorm,朴素实现和 fused kernel 在 H100 上各约多久?
A 张量 64 MiB。朴素写法约 6 次整张量读写 ≈ 384 MiB → 约 120 µs,外加多次 launch;fused 读一次写一次 128 MiB → 约 40 µs。memory-bound 算子的优化 = 减少 HBM 往返。(40a )
Q: torch.compile 做了什么?什么时候仍需手写 kernel?
A TorchDynamo 抓计算图,Inductor 把逐元素 / 归约算子融合成 Triton kernel,矩阵乘仍走 cuBLAS,reduce-overhead 模式加 CUDA Graph。需要改计算顺序的算法(FlashAttention 的 online softmax)、与矩阵乘深度融合(fused MoE、量化 GEMM、fused linear + CE)、吃满新硬件特性时仍要手写。(40a )
回到首页 → README
术语表(Glossary)
用法:按主题分组,组内大致按课程顺序排列。每条的写法是「Term — 先用一句大白话说它是什么 → 关键数字 / 细节 → vs 相邻概念或为什么重要 → 出处课」。遇到不认识的词先 Ctrl+F 搜英文原词;想真懂就点链接回原课。解释和数字都和课程正文一致,如有出入以课程为准。
目录 :Foundations · Transformer · Modern Arch · Training · Inference & Infra · Frameworks & Hardware · 🆕 CS336 补充课
Foundations(基础)
模型、Loss 与优化
Parameters / Weights(参数 / 权重) — 模型里所有可调的数字(记作 θ),训练就是在调它们;下载到的 .safetensors 文件里装的就是这些数。权重显存 ≈ 参数数 × 每参数字节数:8B 参数 × 2 字节(BF16)≈ 16GB。→ 01
Loss Function(损失函数) — 给模型「打分」的函数:把「预测 vs 正确答案」的差距压成一个数,越小越好;训练就是不断调参数让它变小。回归常用 MSE(均方误差),语言模型用 Cross-Entropy。→ 01
Overfitting / Underfitting(过拟合 / 欠拟合) — 过拟合 = 把训练集连噪声一起背下来了(train loss 低、val loss 反而升);欠拟合 = 模型太弱,连规律都没学到。LLM 预训练数据约只过 1 个 epoch(每条数据只看一遍),很少过拟合;SFT 数据小、跑多个 epoch 才是高发区。→ 01 09
Gradient(梯度) — 「每个参数往哪个方向动一点,loss 会上升最快」的方向向量,由 loss 对每个参数的偏导组成。形状和参数完全一样(8B 参数 → 8B 个梯度值),所以训练时梯度本身也要占一份显存。→ 02
Gradient Descent(梯度下降) — 训练的核心一行:θ ← θ − lr × ∇L,即沿梯度的反方向 (loss 下降方向)走一小步,反复迭代。SGD、AdamW 等所有优化器都是这一行的改良版。→ 02
Learning Rate(学习率,lr) — 每一步参数改多少的步长。太大会震荡甚至发散成 NaN,太小则收敛慢、浪费算力。LLM 预训练峰值常在 1e-4 ~ 6e-4(Llama 2 7B 用 3e-4)。→ 02
SGD / Mini-batch SGD — 每步只拿一小批数据(mini-batch)算梯度,用它近似「全量数据的梯度」:有噪声,但每步便宜得多。今天说 SGD 基本都指 mini-batch 版本。→ 02
Backpropagation(反向传播) — 一次性算出「所有参数的梯度」的算法:从 loss 出发,沿计算图倒着逐层应用链式法则。一次 backward 的计算量约为 forward 的 2 倍(每个矩阵乘要分别对输入和权重求导),forward + backward 合计约 3 倍 forward,与参数量无关。vs Optimizer:backprop 只负责算梯度 ,真正改参数的是优化器。→ 03
Computational Graph(计算图) — 把一次计算拆成「节点(中间值)+ 边(谁由谁算出)」的有向无环图(DAG)。forward 时边算边记录这张图,backward 时按拓扑逆序把梯度传回去。→ 03
Autograd(自动微分) — 框架替你做 backprop:每个算子只需实现自己的局部导数,框架按计算图自动拼出全局梯度。PyTorch 的 .grad_fn 就是 micrograd 里的 _backward。梯度默认是累加 (+=)的,所以每步要 zero_grad()。→ 03
Activations(激活值 / 中间结果) — forward 过程中产生、backward 时还要用到的中间张量,大小随 batch × 序列长度增长,训练时常比权重本身还大。vs KV Cache:推理不做 backward,activations 用完即丢,长期保留的只有 KV Cache。→ 03
Activation Checkpointing / Recomputation(重计算) — 用算力换显存:forward 时只存一部分 activations,backward 需要时再重算 forward 补回来,约多花 1/3 计算,换来大量显存。→ 03 28
6ND / 2N — 估算 FLOPs 的两个经验公式:训练总 FLOPs ≈ 6 × 参数量 N × token 数 D(每 token forward 2N + backward 4N);推理每生成 1 个 token ≈ 2N FLOPs。MoE 模型的 N 取激活 参数。→ 03 16 18
神经网络与语言模型
Linear Layer(线性层) — 一次矩阵乘(加偏置):y = Wx + b。参数量 d_in × d_out(外加 d_out 个 bias),每个 token 的 FLOPs 约 2 × d_in × d_out。网络里的参数和计算量几乎全在线性层。→ 04
Activation Function(激活函数) — 夹在线性层之间的逐元素非线性函数(tanh、ReLU、GELU、SiLU)。没有它,多层 Linear 相乘还是一个 Linear(W₂(W₁x) = (W₂W₁)x)。它的计算量可忽略,但单独跑要把整个张量读写一遍显存,所以常和矩阵乘做 kernel fusion。→ 04
MLP(多层感知机) — 最基础的神经网络:Linear → 激活 → Linear。Transformer 里的 FFN 就是一个「先升维再降维」的 MLP。→ 04
Language Model(语言模型) — 给定前文,输出「下一个 token 是词表里每个 token」的概率分布 P(x_t | x_<t)。从 bigram 计数到 Transformer,进化的只是「怎么利用前文」。→ 05
Next-Token Prediction — LLM 预训练的唯一目标:预测下一个 token。由概率的链式法则,把每一步预测乘起来就等价于给整段文本建模。→ 05
Autoregressive(自回归) — 一次只生成一个 token,把它接回输入再生成下一个。生成因此必须串行,这是几乎所有推理优化的出发点。→ 05
Self-supervised(自监督) — 不需要人工标注:训练目标(label)就是输入文本右移一位,所以整个互联网的文本都能直接当训练数据。→ 05
Teacher Forcing — 训练时每个位置都用真实 的前文去预测下一个 token,所以长度 T 的序列一次 forward 就得到 T 个预测、可以完全并行。vs 推理:前文是模型自己刚生成的,只能一个一个来。→ 05
Base Model vs Instruct Model — Base model 是预训练的直接产物,只会「续写文档」;Instruct(Chat)model 再经过 post-training(SFT + RLHF/DPO),学会按指令回答。两者架构和参数量完全相同,差别只在权重。→ 00 05 31
Decoder-only — GPT / Llama / Qwen / DeepSeek 采用的结构:只有带 causal mask(只能看前文)的 Transformer decoder,用来自回归生成。vs Encoder(如 BERT):双向看全文、用「完形填空」(Masked LM)训练,不能直接逐 token 生成。→ 05 11
Token 与 Embedding
Token — 模型真正看到的最小单位:一个整数 id,对应一个子词片段(不一定是完整单词),常带前导空格( the 和 the 是两个不同 token)。上下文长度、API 计费、KV Cache 都按 token 算。→ 06
Tokenizer — 字符串 ⇄ token id 的双向转换器。它是预先用 BPE 单独训练好的,不参与模型的梯度下降;换 tokenizer 基本等于要重训模型。→ 06
BPE(Byte Pair Encoding) — 最主流的 tokenizer 训练算法:从 256 个字节起步,反复把语料里最高频的相邻 token 对合并成新 token,产物是词表(vocab)+ 合并规则(merges)。byte-level BPE 任何字节都能表示,所以永远不会遇到「未登录词」(OOV)。→ 06
Vocab Size(词表大小) — tokenizer 里 token 的总数:GPT-2 为 50,257(256 字节 + 50,000 次合并 + 1 个特殊 token),Llama 3 为 128,256。词表越大,同样文本切出的 token 越少(序列更短、KV 更省),代价是 embedding / LM head 更大、softmax 更贵。→ 06
Special Tokens — 人为加进词表、有特殊含义的单个 token,如 <|endoftext|>、<|im_start|>、<|eot_id|>,用来标记文档边界和对话角色;chat template 全靠它们工作。→ 06 31
Embedding(嵌入) — 把 token id 变成向量的查找表:一个 V × d 的可学习矩阵(V 是词表大小,d 是向量维度),token id 就是行号。数学上等于「one-hot 向量 × 矩阵」,即没有 bias 的 Linear。vs RAG 用的 embedding 模型:后者是另一个独立模型,把整段文本编码成一个向量用于检索。→ 07
LM Head — 模型最后一层:把 d 维 hidden 向量投影回 V 维 logits(每个 token 一个分数)的矩阵,权重同样是 V × d,计算时乘它的转置(h @ Wᵀ)——形状相同,所以才能和 embedding 共享。推理时只需要对最后一个位置算。→ 07 16
Weight Tying(权重共享 / Tied Embedding) — 让 embedding 和 LM head 共用同一个 V × d 矩阵。小模型收益大:GPT-2 small 的 embedding 有 38.6M 参数,占 124M 总量约 31%;共享后省下这 38.6M(不共享时约 163M,少了约 24%)。大模型里 embedding 占比小,一般不 tie(如 Llama-3-8B、Qwen3 8B 以上)。→ 07 16 25
Softmax、Loss 与训练循环
Logits — 模型最后输出的 V 个原始分数(任意实数、还没归一化成概率),softmax 之前的值。大词表下训练时 (B·T) × V 的 logits 张量是显存的隐藏大户。→ 08
Softmax — 把一组分数变成概率分布:每个数取 exp 再除以总和,结果全为正、和为 1。实现上先减去最大值防止 exp 溢出(结果不变)。→ 08
Cross-Entropy / NLL(交叉熵 / 负对数似然) — 语言模型的 loss:−log p(正确 token),模型给正确答案的概率越高 loss 越小;最小化它等于最大似然。随机初始化时应约为 ln V(GPT-2 约 10.8;makemore 的 27 个字符约 ln 27 ≈ 3.30),这是最基本的 sanity check。→ 05 08
Perplexity(困惑度) — exp(loss),直觉是「模型平均在多少个选项里犹豫」。只有在同一 tokenizer、同一数据上才可比。→ 08
Temperature — 采样前把 logits 除以 T,控制「敢不敢选冷门 token」:T<1 分布更尖(更保守),T>1 更平(更随机)。不改变排序 ,T→0 等价于 greedy。它是推理采样参数,LM 训练时恒为 1(知识蒸馏等场景例外)。→ 08 17
Batch / Micro-batch / Global Batch — LLM 一个 batch 的形状是 (B, T)(B 条序列、每条 T 个 token),常用「每步 token 数」衡量(GPT-2 复现用 2^19 ≈ 0.5M)。micro-batch 是单卡一次 forward 的量;global batch = micro-batch × 梯度累积步数 × 数据并行卡数。→ 09
Gradient Accumulation(梯度累积) — 显存放不下大 batch 时,连续跑多个 micro-batch 的 backward,让梯度累加起来,再做一次 step(),数学上等价于一个大 batch。能这样做靠的就是梯度默认 +=。→ 03 09
Adam / AdamW — 最常用的优化器。Adam 为每个参数额外记两个「滑动平均」:一阶矩 m(梯度的平均方向)和二阶矩 v(梯度的平均大小),据此给每个参数自适应步长;AdamW 把 weight decay 从梯度里拆出来单独作用。代价是每参数多 2 个 FP32 状态(8 字节),是训练显存爆炸的主因。→ 09
Weight Decay(权重衰减) — 每步把参数往 0 拉一点的正则化,防止权重无限变大;LLM 常用 0.1,只对 2D 矩阵做,bias 和 Norm 参数不做。vs L2 正则:在 SGD 里二者等价,在 Adam 里不等价,这正是 AdamW 的动机。→ 09 16
Warmup / Cosine Decay — 最常见的学习率曲线:先从接近 0 线性升到峰值(warmup,防止刚开始参数随机时一步走太大而发散),再按余弦曲线降到峰值的约 10%。→ 09
WSD(Warmup-Stable-Decay) — 另一种学习率曲线:峰值 lr 保持很久,只在最后一小段快速衰减。好处是不用事先定死总步数,可以从 stable 阶段任意 checkpoint 分叉出去做 decay。→ 09
Gradient Clipping(梯度裁剪) — 当所有梯度拼起来的总长度(global grad norm)超过阈值(常为 1.0)时,按比例整体缩小,防止单步更新过猛导致 loss spike。→ 02 09
Loss Spike — 训练中 loss 突然跳高甚至发散。应急手段是回滚 checkpoint、跳过那批数据、降 lr;架构上用 QK-Norm 等手段提升稳定性。→ 09 25
Transformer
Attention(注意力) — 让每个 token 去「查」其他 token 并汇总信息的机制,相当于一次可微分的软查表:用 Query 和所有 Key 算相似度,softmax 成权重,再对 Value 加权平均。任意两个 token 之间一步可达、训练可并行,代价是计算量随长度平方增长(O(T²))。vs RNN:信息要一步步传(路径 O(T)),训练无法并行。→ 10
Query / Key / Value(Q/K/V) — 每个 token 的向量分别乘三个学出来的矩阵得到的三个角色:Q =「我想找什么」,K =「我能提供什么(标签)」,V =「被取走的实际内容」。类比数据库:Q 是查询条件、K 是索引、V 是行数据,只是匹配是「软」的(按相似度加权)。→ 10 11
Self-Attention vs Cross-Attention — Self-attention 的 Q/K/V 来自同一序列(GPT 只有这种);Cross-attention 的 Q 来自一个序列、K/V 来自另一个(如翻译模型的 decoder 去看 encoder 的输出)。→ 11
Scaled Dot-Product Attention — Transformer 用的具体公式:softmax(QKᵀ/√d_k + mask)·V(d_k 是每个头的维度)。除以 √d_k 是因为点积的方差随 d_k 增大,不缩放的话 softmax 会饱和成接近 one-hot、梯度消失。PyTorch 对应 F.scaled_dot_product_attention。→ 11
Causal Mask(因果掩码) — 保证每个 token 只能看过去、不能偷看未来:在 softmax 之前 把未来位置的分数设为 −inf(exp 后为 0)。它让训练时一次 forward 并行算出所有位置,也是 KV Cache 成立的前提(过去 token 的 K/V 不会被未来改变)。decode 时唯一的新 Query 本来就在最后,不需要 mask。→ 10 11
Multi-Head Attention(MHA) — 把 d_model 维切成 h 份(h 个「头」),每个头独立做 attention,再拼接起来过输出矩阵 W_O。总参数(4d²)和 FLOPs 与 h 无关,收益是能同时维持多种关注模式(有的头看语法、有的看指代)。→ 12
head_dim / n_head / n_kv_head — 每个头的维度、Query 头数、KV 头数。它们是 GQA、TP 切分、KV Cache 计算的基本单位;Llama 系 head_dim 多为 128。→ 12 19
Positional Encoding(位置编码) — 给模型注入「谁在前谁在后」的信息。Attention 本身只看内容、不看顺序(打乱输入顺序结果只是跟着打乱),所以必须额外加位置。演进:Sinusoidal / Learned Absolute(加在输入向量上)→ 相对位置 bias → RoPE。→ 13
RoPE(Rotary Position Embedding) — 当前主流位置编码:把 Q、K 向量每两维看成一个平面坐标,按「位置 × 频率」旋转一个角度,这样两个 token 的点积只取决于它们的相对距离。用绝对位置实现、得到相对效果,零参数、不动 V。KV Cache 里存的是已经旋转过的 K。→ 13
rope_theta(RoPE base) — RoPE 频率的「底数」,越大最慢那一维转得越慢、能区分的距离越长:Llama 2 为 1 万,Llama 3 调到 50 万,Qwen3 为 100 万,为长上下文留余量。→ 13 23
ALiBi — 另一种位置方案:不改 Q/K,直接给 attention 分数减去 m × |i−j|,距离越远惩罚越大(BLOOM、MPT 使用)。vs RoPE:ALiBi 把位置和内容分开处理,RoPE 把位置揉进 Q/K。→ 13
Residual Connection(残差连接) — 每层输出 = 输入 + 本层算出的增量(x = x + f(x)),层只需学「在原有基础上改多少」;同时给梯度一条不经过矩阵乘的直达通道,让很深的网络也能训练。来自 ResNet。→ 14
Residual Stream — 看待 Transformer 的一种视角:把贯穿所有层的 x 看成一条共享总线,每个子层(attention / FFN)从总线读取(先过 Norm)、把结果加回总线;d_model 就是总线宽度。→ 14
LayerNorm vs BatchNorm — 都是把数值拉回稳定范围的归一化层。LayerNorm 只在单个 token 自己的特征 上归一化,与 batch 里其他样本无关,适合变长序列和逐 token 生成;BatchNorm 依赖同 batch 其他样本的统计量,推理 decode 时行为和训练不一致,所以 LLM 不用。→ 14
RMSNorm — 简化版 LayerNorm:去掉「减均值」和偏置 β,只除以均方根再乘可学习的 γ,参数减半、计算更省而效果几乎不掉,是现代 LLM 标配。实现上常把它的计算提升到 FP32(原因是 BF16 精度不够,不是溢出);推理时常和 residual add 融合成一个 kernel。→ 14
Pre-Norm vs Post-Norm — Norm 放在哪里。Pre-Norm:x + f(Norm(x)),Norm 在分支里,残差主路是纯加法,深层训练稳定、对 warmup 不那么敏感(实践中仍会用 warmup),但需要在最后补一个 final norm;Post-Norm:Norm(x + f(x)),原版 Transformer 的做法,深了必须靠 warmup 才稳。GPT-2 之后基本都是 Pre-Norm。→ 14
FFN(Feed-Forward Network)/ MLP Block — 每个 token 各自独立过的一个「升维 → 非线性 → 降维」小网络,token 之间不交流;在标准 block 里占约 2/3 参数,用了 GQA 的模型里更高。vs Attention:Attention 负责 token 之间的通信,FFN 负责对每个 token 的特征做变换。→ 15
GLU / SwiGLU — 带「门」的 FFN:输入分两路,一路过 SiLU 当 gate(开关),逐元素乘到另一路 up 上,再 down 投影回来。因为有 gate/up/down 三个矩阵,为了和普通 FFN 参数量持平,中间维缩到约 8d/3(Llama-2-7B:8/3 × 4096 ≈ 10923 → 取整到 11008)。Llama-3-8B 在此基础上乘 ffn_dim_multiplier=1.3 再取整到 1024 的倍数,得到 14336(3.5×d),即故意做宽。→ 15
nanoGPT — Karpathy 的 GPT-2 复现仓库,约几百行;model.py 的结构 = embedding → N 个 Pre-Norm Block → final norm → lm_head,是读懂 decoder-only 模型的最短路径。→ 16
12·L·d² — 心算 Transformer 参数量的公式(L 层、隐藏维 d,不含 embedding):每层 attention 4d² + MLP 8d²。GPT-2 small:12 × 12 × 768² ≈ 85M,加 embedding 38.6M ≈ 124M;套到 GPT-3 约 174B(官方 175B)。注意它只适用于 GPT-2 式结构(MHA + 4 倍宽 GELU MLP),GQA / SwiGLU 模型要按实际矩阵重算。→ 16
Decoding Strategy(解码策略) — 从 logits 里「挑下一个 token」的规则:temperature 调尖锐度,top-k / top-p / min-p 砍掉长尾,penalty 抑制重复,最后随机采样或取最大值。实现上都是「把不要的 token 的 logit 置 −inf 再采样」。→ 17
Greedy vs Beam Search — Greedy 每步只取概率最大的 token,简单但容易复读;Beam Search 同时保留 k 条得分最高的候选序列,适合翻译 / 语音识别这类有标准答案的任务,开放式生成里反而乏味重复。→ 17
Top-k / Top-p(Nucleus)/ Min-p — 三种「只在靠谱候选里采样」的截断法:top-k 固定保留概率最高的 k 个;top-p 保留累积概率刚好 ≥ p 的最小集合,候选数随分布自适应;min-p 保留概率 ≥ min_p × 最大概率 的 token,阈值随模型自信度缩放,高温下更稳。→ 17
Repetition / Presence / Frequency Penalty — 给「已经出现过的 token」扣分以抑制复读:repetition penalty 是乘法式(CTRL 论文,θ≈1.2);presence 只看「出现过没有」,frequency 按出现次数扣(OpenAI 风格,取值 −2.0~2.0)。repetition_penalty 通常只在 1.0–1.2 微调,设太大会破坏代码 / JSON 里必要的重复符号。→ 17
Modern Arch(现代架构)
Scaling
Scaling Laws — 经验规律:loss 随参数量 N、数据量 D、算力 C 按幂律平滑下降(Kaplan 2020)。价值在于「可预测」——能用小实验外推大模型的结果,而不是口号「越大越好」。→ 18
Chinchilla / Compute-Optimal — 「固定训练预算下,参数和数据怎么分最划算」的答案:N 与 D 应等比例增长,约 20 tokens / 参数(70B 模型 × 1.4T tokens 胜过 280B 的 Gopher)。注意它只优化训练 成本。→ 18
Over-training(过训练) — 为了让推理便宜,故意训一个更小的模型、喂远超 Chinchilla 比例的数据:Llama-3-8B 训了 15T tokens,约 1900 tokens/参数(15T ÷ 8B ≈ 1875),是 Chinchilla 比例的近百倍。多花的训练算力是一次性的,推理成本是持续的。→ 18 27
Test-time Compute(推理时算力) — o1 / R1 之后的新 scaling 维度:推理时让模型想更久(更长的 CoT)或多采样几次,用算力换准确率。对 infra 意味着 decode 的 token 量暴涨。→ 18 33
Attention 变体与 KV 压缩
MQA(Multi-Query Attention) — 所有 Query 头共用 1 组 K/V,KV Cache 缩到最小;代价是质量有损,而且做 TP 时每张卡都得复制这唯一的一组 KV。→ 19
GQA(Grouped-Query Attention) — MHA 与 MQA 的折中:Query 仍是多头,K/V 头分组共享(如 32 个 Q 头共享 8 个 KV 头),KV Cache 缩小 n_head / n_kv_head 倍,质量几乎不掉。KV 头常取 8,正好与单机 8 卡 TP 对齐。它省的是显存和带宽,不是 FLOPs。→ 19
MLA(Multi-head Latent Attention) — DeepSeek 的 KV 压缩法:不缓存每个头的 K/V,而是缓存一个压缩后的低维 latent 向量(512 维)+ 一个共享的 64 维 RoPE key,需要时再「解压」。DeepSeek-V3 每 token KV 约 69KB(GQA 的 Llama-3-70B 是 320KB)。vs GQA:GQA 减少头数,MLA 做低秩压缩,KV 更小且效果不输 MHA。→ 20
Weight Absorption(权重吸收) — MLA 在 decode 时省掉「解压」的技巧:利用矩阵乘结合律,把「latent → K」的解压矩阵 W_UK 提前乘进 query 一侧、「latent → V」的 W_UV 乘进输出投影,于是可以直接拿 latent 做 attention,形态上等价于 KV 维度 576 的 MQA。用多一点计算换少读很多字节。→ 20
Decoupled RoPE — MLA 为了让 weight absorption 成立而做的拆分:RoPE 的旋转依赖位置,如果夹在 query 和 W_UK 中间,W_UK 就没法提前乘进 query 一侧;所以把位置信息单独拆成一个 64 维、所有头共享的 RoPE key。→ 20
MoE
MoE(Mixture of Experts) — 把每层的 FFN 换成 N 个并列的 FFN(Expert)+ 一个 Router,每个 token 只选其中 top-k 个来算。这样「总参数」(知识容量)和「每 token 计算量」解耦:用更多显存和通信换更少计算。→ 21
Expert — MoE 里的一个 FFN,结构与普通 SwiGLU FFN 完全相同,只是有 N 份独立权重。实际上不存在明显的「数学专家、代码专家」式主题分工。→ 21
Router / Gate — 给 Expert 打分的小线性层(hidden → N):每个 token 取得分最高的 top-k 个 Expert,并用归一化后的分数对它们的输出加权求和。→ 21
Total vs Active Parameters(总参数 / 激活参数) — 总参数 = 所有 Expert 加起来,决定显存;激活参数 = 每个 token 实际经过的参数,决定计算量。Mixtral 8x7B 总 47B / 激活 13B;DeepSeek-V3 671B / 37B;Qwen3-235B-A22B 名字里的「A22B」就是激活参数。→ 21 26
Load Balancing / Aux Loss(负载均衡 / 辅助 loss) — Router 天然会偏爱少数 Expert(强者恒强),导致有的卡忙死、有的闲死。传统做法是在 loss 里加一项「均衡惩罚」,但系数大了伤效果、小了压不住。→ 22
Capacity Factor / Token Dropping — 给每个 Expert 设一个每批最多接收的 token 数(容量),溢出的 token 直接跳过这层 MoE(只走 residual)。vs Dropless:MegaBlocks、DeepSeek-V3 不丢 token。→ 22
Fine-grained Expert(细粒度 Expert) — 把 Expert 切得更小、同时按比例多选几个:计算量不变,但可选的组合数暴增,每个 Expert 更专精。DeepSeek-V3:256 个 routed expert 选 8 个,每个中间维只有 2048。→ 22
Shared Expert(共享专家) — 每个 token 都必经的 Expert,负责通用知识,让 routed expert 少存重复内容。V3 有 1 个;Qwen3 MoE、gpt-oss 没有——是设计选择,不是标配。→ 22
Aux-loss-free Balancing — DeepSeek-V3 的均衡法:不靠 loss,而是给每个 Expert 加一个偏置(bias),只影响「选谁」、不影响输出加权;某 Expert 过载就调低它的 bias、闲着就调高,在线调节。另留一个系数 0.0001 的序列级 balance loss 兜底。→ 22
Node-limited Routing — V3 规定每个 token 最多只能发往 4 个节点上的 Expert,以控制跨节点网络流量——「算法为 infra 让步」的典型例子。→ 22
EPLB / Redundant Experts — DeepSeek 开源的推理侧负载均衡:把线上最热门的 Expert 多复制几份放到不同卡上分流。训练时均衡 ≠ 线上流量均衡。→ 22 44
长上下文
PI(Position Interpolation) — 最简单的上下文扩展:把位置整体按比例压缩(m → m/s),让所有旋转角度落回训练时见过的范围。缺点是高频维度也被压缩,相邻 token 变得难区分,需要微调。→ 23
NTK-aware Scaling — 不改位置、改 RoPE 的 base:效果是高频维度几乎不变、低频维度被插值,比 PI 更温和。→ 23
YaRN — 当前主流的上下文扩展:按维度分段处理——高频维度不动、低频维度做 PI、中间线性过渡;再加温度修正:q、k 各 乘 0.1·ln(s)+1(等价于 attention logits 乘它的平方),把长文本下变「平」的注意力分布重新拉尖(DeepSeek config 里的 mscale)。→ 23
Sliding Window Attention(SWA) — 每个 token 只看最近 W 个 token,KV Cache 被限定在窗口内;多层叠加后「间接能看到」的范围会扩大(Mistral 7B:32 层 × 4096 ≈ 131K)。常与看全文的 global 层交替使用(Gemma 3 为 5:1,gpt-oss 为窗口 128 与 full 交替)。→ 23
Attention Sink — 模型会把大量注意力「倒」给开头几个 token:softmax 权重必须加起来等于 1,没东西可看时需要一个「垃圾桶」。纯滑窗把它们挤出缓存后输出会崩,修复方法是永远保留开头 4 个 token(StreamingLLM);gpt-oss 则给每个头加了一个可学习的 sink logit。→ 23
线性注意力 / SSM / Hybrid
Linear Attention — 去掉 softmax 后,可以先算 KᵀV 得到一个固定大小(d×d)的 state,每来一个新 token 就更新它,推理像 RNN 一样每步 O(1),没有随长度增长的 KV Cache。代价是把历史压缩进定长 state 是有损的,精确召回(如大海捞针、原样复制)差。→ 24
SSM / Mamba — State Space Model(状态空间模型)路线:同样用定长 state 递推。Mamba 让递推参数随输入变化(selective,类似 LSTM 的门);Mamba-2 证明 SSM 与 linear attention 本质同族。→ 24
Delta Rule / Gated DeltaNet — 改进 state 的「写入」方式。Delta rule:先读出 state 里这个 key 对应的旧值,只写入差值(覆盖更新,而非不断累加);Gated DeltaNet 再加一个 gate 负责快速遗忘,兼顾「清空」和「精准改写」,Qwen3-Next 采用。→ 24
Hybrid Architecture — 线性层与 full attention 混搭:大部分层用线性注意力,每隔几层插一层 full attention 保证精确召回(Qwen3-Next 3:1、MiniMax-01 7:1)。推理引擎要同时管理 KV Cache 和定长 state;MiniMax-M2 又退回全 full attention,说明这仍是开放问题。→ 24
训练技巧与读 config
MTP(Multi-Token Prediction) — 训练时让模型一次多预测几个未来 token。DeepSeek-V3 用串行小模块(主模型 hidden + 真实下一个 token 的 embedding → 一个 block → 再预测下一个):训练信号更密;推理时可以直接丢弃,也可以当投机解码的 draft(第二个 token 接受率 85%–90%,约 1.8x TPS)。→ 25 42
QK-Norm — 在 q、k 投影之后、RoPE 之前,对每个头的 q、k 各做一次 RMSNorm,把点积大小锁住,防止 attention logits 越训越大导致 loss spike(Qwen3、Gemma 3 使用)。vs Pre-Norm:Pre-Norm 归一化的是进入 attention 的整条 hidden,管不住 q·k 的尺度。→ 25
Logit Soft-capping — 用 cap · tanh(x / cap) 把数值平滑地限制在 ±cap 以内(Gemma 2:attention 50、最终输出 30)。vs 硬截断 clamp:tanh 处处有梯度。→ 25
QK-Clip(MuonClip) — Kimi K2 的防爆方案:监控每个头的最大 attention logit,超过阈值就直接缩小该头的 W_q / W_k。之所以不用 QK-Norm,是因为 K2 用 MLA,做 weight absorption 后 Key 从未被显式算出来,没地方插 norm。→ 25
config.json — Hugging Face 模型的「架构说明书」:层数、hidden、num_attention_heads / num_key_value_heads / head_dim(算 KV Cache)、Expert 数与 num_experts_per_tok(算总参 / 激活参)、rope_scaling(真实上下文)、quantization_config。坑:MLA 模型不能用 num_key_value_heads 算 KV;hidden_size 也不一定等于 heads × head_dim。→ 26
Training(训练)
数据
FineWeb / FineWeb-Edu — HuggingFace 开源的预训练数据集:从 96 个 Common Crawl 快照清洗出约 15T tokens;再用分类器挑出 1.3T 的「教育类」高质量子集 FineWeb-Edu。→ 27
Dedup(去重):Exact vs Fuzzy — Exact 是对整篇文档算 hash 去掉完全相同的;Fuzzy 去「几乎相同」的:用 MinHash 近似估计文档相似度(Jaccard),再用 LSH 分桶,只比较可能相似的文档对。FineWeb 发现跨快照全局去重反而留下更差的数据,改为快照内去重。→ 27
Quality Classifier(质量分类器) — 给海量网页打质量分的方法:先用大模型标注几十万样本,再训一个便宜的小分类器(fastText / embedding + 线性头)给全量数据打分——「用上一代模型给下一代筛数据」。→ 27
Data Mix(数据配比) — 各类数据(网页、代码、数学、多语言……)的比例,决定模型「偏科」方向。Llama 3 约 50% 通用、25% 数学推理、17% 代码、8% 多语言。→ 27
Annealing(退火) — 训练最后 lr 降到 0 的那段,加大高质量数据(数学、代码、精选文本)的比例,对 benchmark 提升明显。→ 27
精度
FP32 / FP16 / BF16 — 三种浮点格式,都由「指数位(决定能表示多大 / 多小的数)+ 尾数位(决定精度)」组成。BF16 = FP32 砍掉 16 位尾数,范围与 FP32 相同、精度更低,训练基本不溢出,是 LLM 训练与发布的事实标准。vs FP16:FP16 精度比 BF16 高但范围窄(最大 65504),训练需要 loss scaling。→ 28
Mixed Precision(混合精度) — 不同环节用不同精度:矩阵乘用 BF16 / FP8(快、省),master weights、优化器状态、求和类算子留 FP32(防止小的更新被舍入吞掉)。不是「全部转成 BF16」。→ 28
Loss Scaling — FP16 训练的补丁:很小的梯度在 FP16 里会下溢成 0,于是先把 loss 放大若干倍再 backward,更新前再把梯度缩回去(PyTorch GradScaler)。BF16 范围够大,通常不需要。→ 28
16 bytes / param — 混合精度 + Adam 训练时每个参数的显存:BF16 权重 2 + 梯度 2 + FP32 master 4 + Adam m 4 + v 4。7B 模型 → 112GB,单张 80GB 卡放不下(还没算 activations)。这是 ZeRO 论文的估算口径,不同实现会在 12–18 字节间浮动。vs 推理:约 2 bytes/param + KV Cache。→ 09 28
FP8(E4M3 / E5M2) — 8 位浮点,两种位分配:E4M3 精度更高(最大 448),常用于前向的权重 / 激活;E5M2 范围更大(最大 57344),有时用于梯度。训练的关键是细粒度 scaling(一小块数据共用一个缩放系数):DeepSeek-V3 激活按 1×128、权重按 128×128 分块算 scale,矩阵乘的累加定期提升到 FP32,master weights 和优化器状态仍保持高精度。→ 28 43
MFU(Model FLOPs Utilization) — 训练效率指标:实际达到的有效 FLOPs / GPU 峰值 FLOPs。Llama 3 405B 为 38–43%(按 BF16 峰值),好的大规模训练一般在 40–50%。vs MBU:推理 decode 受带宽限制,看带宽利用率。→ 28 30 35
分布式训练
Data Parallel / DDP(数据并行) — 最基础的多卡训练:每张卡放一份完整模型、各跑不同数据,backward 后用 all-reduce 把梯度平均。PyTorch DDP 把梯度分桶(bucket),backward 还没结束就开始通信,让通信与计算重叠。→ 29
All-reduce / Reduce-scatter / All-gather — 集合通信三件套:all-reduce = 所有卡求和后每卡都拿到完整结果;reduce-scatter = 求和后每卡只拿 1/N;all-gather = 把各卡的 1/N 拼成完整一份发给所有卡。ring all-reduce = reduce-scatter + all-gather,每卡通信量约 2 × 数据量,与卡数基本无关。→ 29
ZeRO-1 / 2 / 3 — 去掉数据并行里的冗余存储:ZeRO-1 把优化器状态切成 N 份各存一份,ZeRO-2 再切梯度,ZeRO-3 再切参数本身,每卡模型状态从 16Ψ(Ψ = 参数量)降到约 16Ψ/N。1、2 的通信量与 DDP 相同,3 多一次参数 all-gather(约 1.5 倍)。→ 29
FSDP(Fully Sharded Data Parallel) — PyTorch 原生的 ZeRO-3:参数平时切片存放,算到某一层时 all-gather 拼回完整层,算完即丢,并提前 prefetch 下一层。vs TP:FSDP 只切存储 ,计算时每卡仍算完整的层;TP 真正把计算切开。→ 29 30
Tensor Parallel(TP,张量并行) — 把一层里的大矩阵切到多卡上一起算(Megatron 式):第一个矩阵按列切、第二个按行切,中间的逐元素激活无需通信,每层 forward 只要 2 次 all-reduce。但通信频繁且在关键路径上,所以只放在 NVLink 高速互联域内:通常是单机 8 卡(TP ≤ 8),GB200 NVL72 可到 72 卡。→ 30 44
Sequence Parallel(SP,Megatron-SP) — TP 的补充:把 TP 管不到的 LayerNorm / Dropout 部分按序列维切开,把 all-reduce 拆成 reduce-scatter + all-gather,通信量不变、activation 显存再降。vs CP:SP 依附于 TP;CP 把整条序列(含 attention)切开,是独立的并行维度。→ 30
Pipeline Parallel(PP,流水线并行) — 按层把模型切成几段(stage),每段放一组卡,像流水线一样把 activation 传给下一段。只在段边界点对点通信,通信少、可跨节点;问题是 bubble。→ 30
Bubble(气泡) — 流水线刚启动(填满)和收尾(排空)时,部分 stage 没活干的空等时间,占比约 (p−1)/(m+p−1)(p = stage 数,m = micro-batch 数);增加 m 可以摊薄。→ 30
GPipe / 1F1B / Interleaved 1F1B / DualPipe — 流水线的几种排班法:GPipe 先把所有 micro-batch 的 forward 做完再做 backward;1F1B 让 forward 和 backward 交替,bubble 与 GPipe 相同,但同时在飞的 micro-batch 不超过 p 个,省 activation 显存;interleaved 让每卡负责多个不相邻的小 stage(virtual stage),把 bubble 再缩约 v 倍;DeepSeek-V3 的 DualPipe 从两端双向灌流水,并让计算与通信交错。→ 30
Expert Parallel(EP,专家并行) — MoE 专用:不同 Expert 放在不同卡上,每层 MoE 前后各一次 all-to-all(dispatch:把 token 发到它选中的 Expert 所在卡;combine:把结果发回来)。→ 21 30
All-to-all — 每张卡给每张卡都发一份不同 数据的集合通信(类似 MapReduce 的 shuffle);是 MoE 的主要通信开销。DeepSeek 开源的 DeepEP 专门优化它。→ 21 30 44
Context Parallel(CP)/ Ring Attention / Ulysses — 超长序列训练时把一条序列切到多卡上。难点是 attention 需要看全序列:Ring Attention 让 K/V 块沿环传递、边传边算;Llama 3 直接 all-gather K/V;DeepSpeed-Ulysses 用 all-to-all 把「按序列切」转成「按 head 切」。→ 30
3D / 4D Parallelism — TP × PP × DP(再 × CP / EP)组合使用,原则是「通信越密的越往里放」:TP 在节点内、PP 可跨节点、DP 在最外层。DeepSeek-V3(2048 张 H800)甚至不用 TP:PP16(DualPipe)× EP64(跨 8 节点)× ZeRO-1 DP。→ 30
Post-training
SFT(Supervised Fine-Tuning) — 用「示范对话」继续训练:把对话按 chat template 拼成 token 序列,照常做 next-token prediction,但 loss 只算 assistant 回复部分(含结束 token)。主要教格式和风格,而非新知识。→ 31
Chat Template — 把多轮对话拼成一条 token 序列的格式规范:用 special token 标出 system / user / assistant 各轮的边界。它是训练与推理之间的「协议」,推理端模板或 stop token 配错会导致复读、停不下来。→ 31
Rejection Sampling(拒绝采样,post-training 语境) — 造 SFT 数据的方法:对同一 prompt 采多个回答,只留最好的。Llama 3 的 post-training 是多轮「SFT → rejection sampling → DPO」。vs 投机解码里的 rejection sampling:同名但完全不同,后者是一条保证输出分布不变的接受 / 拒绝规则。→ 31 42
RLHF(Reinforcement Learning from Human Feedback) — 用人类偏好来训练模型:先用「人类更喜欢哪个回答」的数据训一个 Reward Model,再用强化学习(PPO)让模型多生成高分回答,同时用 KL 约束别偏离原模型太远。代表作 InstructGPT。→ 32
Reward Model(RM)/ Bradley-Terry — 给回答打分的模型:用 SFT 模型初始化,把输出层换成一个标量分数,在偏好对上训练「被选中的回答分数高于被拒的」(Bradley-Terry 模型)。只有相对分数有意义。→ 32
PPO(Proximal Policy Optimization) — RLHF 用的强化学习算法:通过限制(clip)新旧模型的概率比,防止一次更新走太远。RLHF 版要同时跑 4 个模型:actor(被训练的模型)、critic(估计价值,也要训练)+ reward model、reference(都冻结)。→ 32
KL Penalty / Reward Hacking — Reward hacking = 模型钻 Reward Model 的空子(越写越长、堆讨好词),RM 分数上升但人觉得更差;KL penalty 惩罚模型偏离原模型太远来抑制它。即便如此 RLHF 也不能长跑。→ 32
DPO(Direct Preference Optimization) — 跳过 Reward Model 和 RL 的偏好训练:利用数学推导(KL 约束下最优策略有闭式解),把 RLHF 目标改写成在偏好对上的一个分类式 loss,只需 policy + reference 两个模型、不需要在线生成。代价是 offline,只能从固定数据集学、无法探索。→ 32
Online vs Offline RL — Online(PPO、GRPO)边训练边用当前模型现场生成回答来学,上限高但工程重;Offline(DPO)只从固定数据集学。iterative / online DPO 是折中。→ 32
RLVR(RL with Verifiable Rewards) — 奖励来自能自动验证的规则(数学答案比对、代码单元测试),而不是神经网络打分,很难被钻空子,所以可以像 AlphaGo 那样一直训下去。→ 33
GRPO(Group Relative Policy Optimization) — DeepSeek 的简化版 PPO:同一道题采 G 个回答,每个回答的 advantage(比平均好多少)= (它的 reward − 组内均值) / 组内标准差,用「同组比较」代替了 critic 模型(std 用总体还是样本标准差只影响幅度、不影响正负;torch.std 默认是样本标准差)。出自 DeepSeekMath;标准 GRPO 仍用 reference 算 KL。一组全对或全错时 advantage 全为 0,不产生梯度。→ 33
CoT(Chain-of-Thought)/ Reasoning Model — 先写一长段思考过程再给答案的模型(o1、R1)。R1-Zero 证明:只靠「答案对不对」的 RL 奖励,就能自发涌现长 CoT 和自我反思。→ 33
Rollout — RL 训练中「让当前模型批量生成回答」这一步,本质是大规模推理,占 RL step 的大部分时间;回答长短不一(长尾),一批要等最长的那个,GPU 大量空等。对策:continuous batching、partial rollout、异步 RL。→ 33
Colocate vs Disaggregated(RL 放置) — RL 训练和 rollout 推理怎么分配 GPU:colocate = 同一批卡分时切换(要把一方 offload 出显存);disaggregated = 分两批卡(要跨机同步权重)。无论哪种,每步都要在训练布局(FSDP/Megatron)与推理布局(vLLM/SGLang 的 TP/EP)之间转换权重。→ 33
PEFT
LoRA(Low-Rank Adaptation) — 最流行的轻量微调:冻结原权重 W,只训练一个低秩增量 ΔW = B·A(两个细长矩阵,秩 r 常为 8–64);B 初始化为 0,保证起点就是原模型。省掉的是 base 参数的梯度和优化器状态,activation 显存并不省。→ 34
Merge vs Unmerged — LoRA 部署的两种方式:merge 把增量加回 W' = W + (α/r)BA,结构不变、零额外延迟;不 merge 则可以按请求动态挂载不同 adapter。→ 34
QLoRA / NF4 — 把冻结的 base 模型压成 4-bit 存,再在上面训 LoRA:NF4 是针对正态分布权重设计的 4-bit 格式,前向时逐块反量化成 BF16 计算,梯度只流向 LoRA;另有 double quantization、paged optimizers。65B 模型可在单张 48GB 卡上微调。→ 34
PEFT(参数高效微调) — 只训练一小部分参数的微调方法统称:LoRA、Adapter(会增加推理延迟)、Prefix / Prompt Tuning、DoRA 等。LoRA 胜在可 merge、零推理开销。→ 34
Multi-LoRA Serving — 一个 base 模型同时服务成百上千个 LoRA adapter:base 部分对所有请求做一次共享的大矩阵乘,各请求的 adapter 部分用 SGMV 类 kernel 分段计算(S-LoRA、Punica);vLLM 用 --enable-lora 开启。→ 34
Inference & Infra(推理与系统)
GPU 与性能模型
SM(Streaming Multiprocessor) — GPU 里的「核」,H100 SXM 有 132 个;一个 kernel 被拆成很多 thread block,分发到各个 SM 上并行执行。→ 35
Tensor Core — SM 里专门做小块矩阵乘加的硬件单元;LLM 超过 90% 的 FLOPs 是矩阵乘,所以「GPU 算力」基本就指它。H100 BF16 dense 约 989 TFLOPS(宣传页的 1979 是 2:4 稀疏数字)。→ 04 35
HBM(High Bandwidth Memory) — GPU 的显存:容量大,但相对计算单元「远」、带宽有限;权重和 KV Cache 都住在这里。H100 约 3.35 TB/s,H200 为 141GB / 4.8 TB/s。→ 35
SRAM(Shared Memory / L1 / Register) — 每个 SM 旁边的片上存储,类似 CPU 的 L1 cache:极快但极小(每 SM 两百多 KB,全卡 L1/Shared 合计约 33MB = 132 × 256KB),只能放正在算的小块(tile)。→ 35
Arithmetic Intensity(算术强度) — 每从显存搬 1 字节数据,能做多少次计算(FLOP/byte)。低于硬件平衡点就是 memory-bound(等数据)。线性层的算术强度 ≈ 这次 forward 同时处理的 token 数。→ 35 36
Ridge Point(平衡点) — 硬件的峰值 FLOPS ÷ 峰值带宽,即「算力和带宽刚好都用满」时需要的算术强度:A100 约 156、H100 约 295 FLOP/byte。→ 35
Compute-bound vs Memory-bound — 瓶颈在算力还是在搬数据。Compute-bound:prefill、训练、大矩阵乘;memory-bound:decode、矩阵 × 向量、逐元素算子。memory-bound 的优化方向是少搬字节,而不是少算。→ 35 36
Roofline Model — 判断瓶颈的一张图:横轴算术强度、纵轴可达性能,形状像屋顶——左边斜线段受带宽限制,右边平顶受算力限制,拐点就是 ridge point。→ 36
GEMM vs GEMV — 矩阵 × 矩阵(算术强度可上千,compute-bound)vs 矩阵 × 向量(batch=1 decode 就是这种,强度约 1,严重 memory-bound)。→ 04 35
Kernel — 在 GPU 上并行执行的一个函数(一次 launch)。朴素实现下每个算子是一个 kernel,每次都要把张量在显存里读写一遍,外加几微秒的 launch 开销。→ 35
Kernel Fusion(算子融合) — 把多个算子合并成一个 kernel,中间结果留在片上 SRAM / 寄存器里、不回显存(如 bias + activation、residual add + RMSNorm)。FlashAttention 是它的极致。→ 04 14 35
CUDA vs Triton — 写 GPU kernel 的两种方式。CUDA 以单个线程为视角,自己管 shared memory 和同步,性能上限最高;Triton 是 Python DSL,以「一块数据」为视角,线程映射交给编译器。torch.compile 生成的就是 Triton 代码。→ 35
CUDA Graph — 把一串 kernel launch 预先「录制」下来,之后每步整体「回放」,省掉 decode 每步几百次 launch 的 CPU 开销。vLLM 用 piecewise CUDA Graph(形状多变的 attention 留在图外)。→ 35 45
MBU(Model Bandwidth Utilization) — decode 阶段的效率指标:实际达到的带宽 / 峰值带宽。vs MFU:训练和 prefill 受算力限制,看算力利用率。→ 35 36
两阶段与 KV Cache
Prefill — 推理第一阶段:把整个 prompt 一次性并行过一遍模型、填好 KV Cache,并产出第一个 token。算术强度 ≈ prompt 长度,compute-bound;决定 TTFT。→ 36
Decode — 推理第二阶段:每步只生成 1 个 token,却要把全部权重和 KV Cache 从显存读一遍,memory-bound。单请求速度上限 ≈ 带宽 ÷ 每步读取字节(Llama-3-8B BF16 在 H100 上约 210 tokens/s)。→ 36
Batching(推理) — 让多个请求共用同一次权重读取:在 memory-bound 区间内,单步时间基本不变而吞吐近线性增长,TPOT 几乎不变。但每个请求各自的 KV 读取不共享,上下文长时收益打折。→ 36
KV Cache — 把每个已处理 token 在每一层的 K、V 向量存起来,decode 时只需为新 token 计算,不用重算历史(用显存换计算)。每 token 大小 = 2 × n_layers × n_kv_heads × head_dim × bytes:Llama-3-8B 128KB,Llama-3-70B 320KB,DeepSeek-V3(MLA)约 69KB。特点:按请求隔离、只追加、最终长度未知。并发上限 ≈ KV 可用显存 ÷(每 token KV × 平均上下文);vLLM 按 gpu_memory_utilization(默认 0.92)扣掉权重和激活后全给 KV,启动日志的 GPU KV cache size 就是这个数。→ 37
KV 管理与调度
PagedAttention — 把 OS 虚拟内存分页搬到 KV Cache:KV 切成固定大小的 block(默认 16 token,相当于「页」),用 block table(「页表」)把逻辑上连续的序列映射到物理上分散的显存,用多少分配多少。显存利用率从 20–40% 提到约 96%,吞吐 2–4 倍。它不减少每 token 的 KV 字节,减少的是浪费;代价是 attention kernel 要查表,论文测得比 FasterTransformer 慢 20–26%。如今 paged KV 已是 FlashAttention / FlashInfer 的标准输入格式,vLLM 早期自带的 kernel 已从 main 移除。→ 38
Internal vs External Fragmentation — 内部碎片 = 分出去的 block 里没写满的空位(最多 block_size − 1 个);外部碎片 = 空闲显存被切成大小不一的空洞、凑不出连续大块。PagedAttention 消除外部碎片,内部碎片只出现在每个请求的最后一个 block。→ 38
Copy-on-Write(写时复制) — 和 OS fork 一样:block 带引用计数,可被多个序列共享,谁要写才复制一份。论文中 parallel sampling 省 6.1–9.8% 显存,beam search 省 37.6–55.2%。这是论文 / V0 的机制;vLLM V1 把 n>1 拆成 n 个子请求,改由 prefix caching 共享写满的 prompt block。→ 38
Static / Dynamic / Continuous Batching — Static 与 dynamic 都是「请求级」调度:攒一批、跑到全部结束才换下一批,短请求要陪长请求空转;continuous batching(Orca 提出的 iteration-level scheduling)每生成一个 token 就重新组 batch,结束的请求立刻离开、新请求立刻加入。TensorRT-LLM 叫它 in-flight batching。→ 39
Selective Batching — 让不同请求能拼进一个 batch 的技巧(Orca):线性层对每个 token 独立计算,把所有请求的 token 展平成 [总 token 数, hidden] 一起算;只有 attention 需要按请求分开。今天由 varlen / paged attention kernel 实现。→ 39
Chunked Prefill — 给每一步设一个 token 预算,把长 prompt 切成小块、与正在进行的 decode 混在同一步跑(Sarathi-Serve 的 stall-free batching),避免一个大 prefill 让所有 decode 卡顿。总计算量基本不变(只是后面的 chunk 要多读几次前面已算好的 KV),TPOT 更平稳,代价是这个长请求的 TTFT 略增。→ 39 45
Preemption:Swap vs Recompute — 显存不够时把某个正在跑的请求踢出去,之后再恢复:swap 把它的 KV 拷到 CPU 内存、回来时拷回(占 PCIe 和 CPU 内存);recompute 直接丢掉 KV,回来时重做一次 prefill。vLLM 论文实验:block 小时 recompute 占优、大时 swap 占优,16–64 之间两者相近。V1 只做 recompute,主要因为逻辑简单、不占 CPU 内存和 PCIe,还能命中 prefix cache,而不是因为它一定更便宜。默认后到的请求先被抢占(priority 策略下抢占优先级最低的)。→ 39
Attention Kernel
FlashAttention — 计算结果完全精确、但少读写显存的 attention kernel:把 Q/K/V 切成小块(tile)放进片上 SRAM,用 online softmax 边算边合并,从不把 N×N 的分数矩阵写回显存。FLOPs 不减反增(backward 要重算),显存从 O(N²) 降到 O(N)。vs PagedAttention:FA 优化单次 attention 计算的 IO,Paged 管理跨步的 KV 存储,二者组合使用。→ 40
Online Softmax — 让 softmax 能分块算的数学技巧:一边扫描一边维护「目前的最大值 m」和「目前的指数和 l」,遇到更大的最大值时,把已累积的结果乘 exp(m_old − m_new) 换到新基准上。结果精确、各块可合并,是 FlashAttention 的数学基础。→ 40
FA1 / FA2 / FA3 / FA4 — FlashAttention 的历代版本:FA1 解决显存 IO;FA2 减少非矩阵乘的计算、提升并行度(A100 上达峰值 50–73%);FA3 利用 Hopper 的异步硬件(TMA/WGMMA)与 FP8(H100 FP16 达 740 TFLOPs/s);FA4 面向 Blackwell。→ 40
Flash-Decoding — decode 专用的 FlashAttention 变体:decode 时 query 只有 1 个,按 query 分块并行度不够,于是改为沿 KV 长度切成多段、交给多个 thread block 并行算,最后用 online softmax 的规则合并。→ 40
前缀复用
Prefix Caching — 跨请求复用相同开头的 KV:因为 causal attention 下「前缀相同 → KV 相同」,第二个请求可以直接用第一个算好的 KV,省掉这部分 prefill(降 TTFT),decode 不变。必须从第一个 token 起完全相同,所以 prompt 要把固定内容放前、可变内容放后。→ 41
Automatic Prefix Caching(APC,vLLM) — vLLM 的实现:每个写满的 block 算一个链式哈希(父 block 哈希 + 本块 token + LoRA 等额外 key),像查 hash 表一样找可复用的 block;请求结束后 block 保留内容和哈希当缓存,按 LRU 淘汰,粒度是 block。V1 默认开启,官方称命中率 0% 时吞吐损失也 <1%。→ 41 45
RadixAttention(SGLang) — SGLang 的实现:用 radix tree(压缩前缀树)组织所有缓存的 token 序列,做最长前缀匹配,优先淘汰叶子(LRU),引用计数保护正在用的节点,粒度可到单个 token。它是 KV 复用的管理机制 ,不是新的 attention kernel。→ 41 46
Cache-aware Scheduling(LPM) — 等待队列按「最长前缀匹配」(Longest Prefix Match)排序,让共享前缀的请求挨着跑,提高命中率;队列超过 128 个请求时 SGLang 退化为先来先服务(FCFS)。→ 41
Cache-aware Routing — 多副本部署时,把前缀相同的请求发往同一个实例;普通负载均衡随机分发会把缓存打散(sgl-router〔仓库现名 sgl-model-gateway〕、llm-d)。→ 41 46
KV Offload / 多级 KV 缓存 — 像多级存储一样,把暂时不用的 KV 从显存卸载到 CPU 内存 / SSD / 远端存储,需要时再取回(LMCache、Mooncake、SGLang HiCache)。→ 37 41 44
投机解码
Speculative Decoding(投机解码) — 「小模型先猜、大模型批改」:便宜的 draft 先连猜 γ 个 token,target(真正的大模型)一次 forward 并行验证全部;用 rejection sampling 保证输出分布与 target 自己生成严格一致(无损)。本质是用 decode 时闲置的算力换更少的权重读取,主要降低小 batch 下的 TPOT。→ 42
Acceptance Rate(接受率 α) — draft 每个 token 被 target 接受的概率。每轮期望产出 (1 − α^{γ+1}) / (1 − α) 个 token(α=0.8、γ=4 时约 3.36);一旦某个被拒,后面全部作废,所以 γ 边际收益递减,α 比 γ 更重要。→ 42
Rejection Sampling(投机解码语境) — 保证「猜了也不改变结果」的验证规则:draft 给某 token 概率 q、target 给 p,以 min(1, p/q) 接受;被拒则从残差分布 norm(max(0, p − q)) 重采样。全部接受时还能白得一个 bonus token。→ 42
Medusa — 不用单独的 draft 模型,而是在 target 顶上挂几个额外的预测头,第 i 个头猜第 +i 个位置,再用 tree attention(用 mask 让一次 forward 验证一整棵候选树)一次验证多条组合。各头互相独立,越远越不准;论文报告 2.2x(Medusa-1)/ 2.3–3.6x(Medusa-2)。→ 42
EAGLE / EAGLE-2 / EAGLE-3 — 在「特征」层面猜的 draft:用约 1 层的小 Transformer,吃 target 在 LM head 之前那一层的 hidden state(feature,即最后一层 Transformer 的输出,论文称 second-to-top-layer feature)+ 已采样 token 的 embedding,自回归地预测下一个 feature,再复用 target 的 LM head 出 token;feature 比 token 更平滑、更好猜。EAGLE-2 按置信度动态扩展候选树;EAGLE-3 改为直接预测 token、融合低 / 中 / 高多层 feature,并用 training-time test,论文报告最高约 6.5x。→ 42
N-gram / Prompt Lookup — 最便宜的 draft:在 prompt 里找和最近几个 token 匹配的片段,直接把它后面的内容抄过来当猜测。零训练,但只在 RAG、代码编辑、摘要这类「大量复读输入」的场景有效。→ 42
量化
Quantization(量化) — 用更少的 bit 存储权重 / 激活 / KV Cache(如 16 bit → 8 或 4 bit),换更小的显存和更少的数据搬运。核心敌人是 outlier(少数特别大的值,会让整体精度被拉低)。→ 43
Scale Granularity(per-tensor / per-channel / per-group) — 量化时「多少个数共用一个缩放系数 scale」:整张量一个、每个通道一个,还是每一小组一个。越细精度越好,但 scale 本身占空间、kernel 更复杂。INT4 常用 group=128:FP16 scale 每 128 个参数多 2 字节,相对 4-bit 权重约 +3% 体积(约 4.125 bit/参数)。→ 43
Weight-only(W4A16)vs W8A8 — W4A16 只把权重压到 4 bit、计算时再反量化回 16 bit,减少的是 decode 要搬的字节(针对 memory-bound):单请求 decode 理论上 INT4 约快 4 倍、FP8/INT8 约 2 倍(实测打折扣)。W8A8 / FP8 让权重和激活都是 8 bit,矩阵乘直接跑在低精度 Tensor Core 上(针对 compute-bound)。所以大 batch 时 INT4 不一定比 FP8 快。→ 36 43
GPTQ — 经典的 weight-only 量化算法:一列一列地量化,每量化一列就把产生的误差(借助 Hessian 近似)补偿到还没量化的列上。约 4 GPU 小时就能把 175B 模型压到 3–4 bit。→ 43
AWQ(Activation-aware Weight Quantization) — 另一种 weight-only 量化:发现约 1% 的「重要」通道(对应激活很大的那些)决定质量,量化前把这些通道的权重乘一个 scale 放大、激活侧除回去(数学等价),让它们的相对量化误差变小。不需要反向传播。→ 43
SmoothQuant — 让激活也能量化到 INT8 的技巧:激活的 outlier 集中在少数固定通道、很难量化,而权重很平滑;于是按通道把激活除以 s、权重乘以 s(结果不变),把「难量化」从激活挪到权重上。→ 43
MXFP4 vs NVFP4 — 两种 4-bit 浮点格式,都是「每一小块共享一个 scale」:MXFP4 每 32 个元素共享一个 2 的幂次 scale(E8M0),约 4.25 bit/元素,gpt-oss 的 MoE 权重用它;NVFP4 每 16 个元素一个 FP8 E4M3 scale,外加一个 per-tensor scale,约 4.5 bit,精度通常更好。→ 43
KV Cache Quantization — 把 KV Cache 压成低精度,主流是 FP8(--kv-cache-dtype fp8),同样显存能放两倍 token;更激进的 INT4 / FP4 KV 也在出现。长上下文、高并发时常比权重量化更划算。→ 37 43
GGUF — llama.cpp 使用的本地模型文件格式,自带多种量化方案(如 Q4_K_M)。→ 43 47
分布式推理
TP(推理) — 推理时的张量并行:每层 2 次 all-reduce,对延迟敏感(Llama-3-70B 80 层,decode 一步就 160 次),只放 NVLink 域内;好处是能降低单请求延迟。KV head 数少于 TP 卡数时 KV 要复制。→ 44
PP(推理) — 推理时的流水线并行:按层切 stage,通信少、可跨机,但单请求延迟不降反增,主要用于「一台机器放不下模型」。→ 44
DeepEP — DeepSeek 开源的 MoE 专家并行通信库,提供两套 kernel:normal kernel 追求高吞吐(用于 prefill),low-latency kernel 纯 RDMA、兼容 CUDA Graph(用于 decode)。→ 21 44
DP-Attention — SGLang 为 MLA 模型设计的并行方式:attention 部分按请求 分到各卡(每卡只存自己那批请求的 KV,避免 MLA 的共享 latent KV 在 TP 下被每卡复制),MoE 部分走 EP。→ 20 44
PD Disaggregation(PD 分离) — 把 prefill 和 decode 放进两个独立的 GPU 池,各自扩缩、各选并行策略,消除长 prefill 对 decode 的干扰;优化目标从 throughput 变成 goodput。代价是 KV 要跨机传输、两池配比难调。短 prompt / 低并发时未必划算,chunked prefill 也能缓解干扰。→ 44
Mooncake — Kimi(月之暗面)的「以 KV Cache 为中心」的推理架构:把集群里闲置的 CPU 内存 / SSD 组成分布式 KV 池,调度时优先考虑「KV 在哪」;其 Transfer Engine 被 vLLM / SGLang 用作 KV 传输后端。→ 44
引擎架构与指标
vLLM V1 三层进程 — vLLM V1 把工作拆到不同进程:API Server(AsyncLLM:tokenize / detokenize / 流式返回)⇄ ZeroMQ ⇄ EngineCore(死循环:Scheduler + KVCacheManager)⇄ Executor → Worker → GPUModelRunner(每 GPU 一个进程)。目的是把 Python 的 CPU 杂活移出 GPU 关键路径。→ 45
num_computed_tokens 模型 — vLLM V1 调度器的统一抽象:不区分 prefill / decode,每个请求只记「已算了多少 token(num_computed_tokens)」和「总共有多少 token(num_tokens;开投机解码时是 num_tokens_with_spec)」,每步在 token 预算内分配 {request_id: num_tokens} 让前者追上后者。chunked prefill、prefix caching、投机解码都是它的特例。→ 45
SRT(SGLang Runtime) — SGLang 的进程流水线:TokenizerManager → Scheduler(与 TpModelWorker 同进程)→ DetokenizerManager。vs vLLM:detokenize 单独成进程,而调度与执行在同一进程。→ 46
Overlap Scheduler(零开销调度) — 让 CPU 永远提前一步:GPU 跑 batch N 时,CPU 同时处理 N−1 的结果并调度 N+1。难点是 N+1 的输入就是 N 刚采样出的 token,此时 CPU 还不知道——解法是 future token 占位 :CPU 只记下「去 GPU 上 future buffer 的第几格取」,GPU 按顺序执行到 N+1 时,N 已把真 token 写进那格,直接读取,全程不用 CPU 等 GPU。成本仍在,只是被藏进 GPU 时间里;必须同步的 batch(如某些 grammar / spec 场景)会关闭 overlap。vLLM 的 --async-scheduling 思路相同。→ 45 46
Structured Output(结构化输出) — 保证输出符合 JSON schema / 正则 / 语法:每步采样前把所有「会导致格式不合法」的 token 的 logit 置 −inf。→ 17 46
Compressed FSM / Jump-forward — 把 schema 编译成有限状态机(FSM);当某个状态只有唯一合法的后续字符串(如 JSON 的固定 key 和标点)时,直接整段追加,省掉逐 token 的 forward。→ 46
xgrammar — 结构化输出引擎:支持完整的上下文无关文法,把大部分 token 的合法性提前算成 bitmask,运行时只处理少量上下文相关 token,并与 GPU 计算重叠,开销极低。是当前 SGLang 默认的 grammar backend(v0.4 时需 --grammar-backend xgrammar 手动开启)。→ 46
TTFT(Time To First Token) — 请求到达到第 1 个 token 返回的时间,≈ 排队 + prefill;受 prompt 长度和 prefix cache 命中影响。→ 36 47
TPOT vs ITL — 两种衡量「后续 token 吐得多快」的方式:TPOT = (E2E − TTFT)/(n − 1),每个请求算一个平均值;ITL 统计每两个相邻 token 之间的间隔。P99 ITL 能暴露长 prefill 插队造成的偶发卡顿,平均值看不出来。→ 47
Throughput vs Goodput — Throughput 是每秒处理的请求数或 token 数;Goodput 是每秒同时满足所有延迟 SLO 的请求数(DistServe 提出),才是真实可用容量。→ 44 47
SLO / P99 — 服务等级目标,线上用百分位来定,如「P99 TTFT < 1s 且 P99 TPOT < 50ms」;具体值取决于产品形态(聊天、代码补全、批处理)。→ 47
Pareto / Tradeoff Curve — 「单用户速度(1/TPOT)vs 单卡吞吐」的曲线,每个点对应一个并发度:并发越高吞吐越大、单用户越慢。比较框架要在你的 SLO 处比曲线,而不是只比最大吞吐。→ 47
Little's Law — 排队论基本公式:系统内并发数 = 吞吐 × 平均延迟。5 req/s × 平均 8s = 40 个在跑请求,KV Cache 要按 40 个请求的上下文预留。→ 47
Frameworks & Hardware(框架与硬件)
PyTorch — 主流深度学习框架;torch.no_grad() / inference_mode() 关闭计算图记录,推理引擎都跑在它之上。→ 03
torch.compile — PyTorch 的编译器:把模型代码编译成融合后的 Triton kernel;vLLM 用自定义的编译 backend。→ 35 45
Hugging Face(transformers / tokenizers / safetensors) — 开源模型发布的事实标准,一个模型通常包含 config.json(架构)+ *.safetensors(权重)+ tokenizer.json(词表)+ generation_config.json(默认采样参数)。→ 00 06
tiktoken / SentencePiece — 两个 tokenizer 库:tiktoken 是 OpenAI 的高速 BPE 编解码库;SentencePiece 是 Google 的可训练 tokenizer(Llama 1/2 使用)。→ 06
Megatron-LM / Megatron-Core — NVIDIA 的大模型训练框架,TP / PP / EP / CP 的事实标准;--use-distributed-optimizer 即 ZeRO-1。→ 29 30
DeepSpeed — 微软的训练库,ZeRO 1/2/3、Offload、Ulysses 的出处。→ 29 30
NCCL — NVIDIA 的 GPU 集合通信库(all-reduce / all-gather / reduce-scatter 等),多卡训练和推理的通信底座。→ 29
NVLink vs InfiniBand / RoCE — 节点内 GPU 之间的高速直连(H100 双向合计约 900GB/s,单向约 450GB/s)vs 节点之间的网络(每卡常见 400Gb/s),带宽差约一个数量级;这决定了 TP 放节点内、PP/DP 可以跨节点。→ 29 44
TRL / OpenRLHF / verl — post-training 框架:TRL(Hugging Face,DPOTrainer / GRPOTrainer);OpenRLHF(Ray + vLLM + DeepSpeed);verl(字节 Seed,HybridFlow,单控制器编排训练与推理引擎)。→ 32 33
vLLM — UC Berkeley 出身、现为 PyTorch Foundation 项目的开源推理引擎;靠 PagedAttention 起家,今天的竞争力在调度、CPU 开销优化、编译和最广的模型 / 硬件生态。→ 38 45 47
SGLang — LMSYS 等社区主导的开源推理引擎;特色是 RadixCache、overlap 调度、结构化输出、DeepSeek 类模型的大规模 EP / PD 分离,常被用作 RL rollout 引擎。→ 41 46 47
TensorRT-LLM — NVIDIA 的推理引擎,在最新 NVIDIA 硬件上有极致 kernel 与 FP8 / FP4 支持。1.0 起以 PyTorch 后端为默认,早期「先 build TensorRT engine 再运行」的流程退为 legacy;只支持 NVIDIA 硬件。continuous batching 在这里叫 in-flight batching。→ 39 47
LMDeploy / llama.cpp / Ollama — LMDeploy(上海 AI Lab / InternLM 团队,TurboMind〔C++/CUDA〕+ PyTorch 两套引擎,4-bit 量化推理强);llama.cpp(C/C++ 实现、GGUF 格式,跑在 CPU / Apple Silicon 上,面向本地单用户);Ollama 在 llama.cpp 之上封装了模型下载和管理。→ 47
FlashInfer — attention 与采样的 kernel 库(支持 paged KV、MLA、免排序的 top-k/top-p 采样),vLLM / SGLang 的主要 kernel 后端之一。→ 17 40
DeepGEMM / DeepEP / EPLB / FlashMLA / 3FS — DeepSeek「Open Infra」系列开源项目:FP8 矩阵乘、MoE 专家并行通信、Expert 负载均衡、MLA 的 attention kernel、分布式文件系统。→ 20 22 44
Dynamo / llm-d — 架在推理引擎之上的编排层:负责 PD 路由、按 KV 位置路由、弹性扩缩;不替代 vLLM / SGLang 本身。→ 44 47
Nsight Systems / Nsight Compute(nsys / ncu) — NVIDIA 的两个性能分析工具:nsys 看整体时间线和 kernel 之间的空隙;ncu 深入单个 kernel,看它是 compute-bound 还是 memory-bound。→ 35
A100 / H100 / H200 / B200 — 四代主力数据中心 GPU:A100 312 TFLOPS BF16、约 2.0 TB/s,不支持 FP8;H100 989 TFLOPS、3.35 TB/s,FP8 的甜点卡;H200 算力同 H100,但 141GB / 4.8 TB/s,「为推理加料」;B200 / GB200 原生支持 FP4。→ 35 43
GB200 NVL72 — 72 张 GPU 在同一个 NVLink 域里的整机柜,让大规模 TP / EP 全部走 NVLink 高速互联。→ 44
🆕 CS336 补充课(Scaling 实战 · 评测 · 多模态 · Kernel)
IsoFLOP(等算力曲线) — 固定训练算力 C,扫不同模型大小 N(D = C/6N)得到的一条 loss 曲线;谷底就是该算力下的 compute-optimal N。Chinchilla、Llama 3 都用它拟合 scaling law。 → 18 18a
μP(Maximal Update Parametrization) — 一套按宽度缩放初始化和各层 lr 的参数化方式,让最优超参不随模型宽度漂移,从而在小代理模型上调参、直接迁移到大模型(μTransfer)。vs 标准参数化:后者模型越宽最优 lr 越小。 → 18a
Critical Batch Size — batch 小于它时,翻倍 batch ≈ 步数减半、总算力不变;超过它就开始浪费算力。loss 越低它越大,决定了数据并行能铺多宽。 → 18a
WSD(Warmup-Stable-Decay) — 学习率调度:warmup 后长时间保持恒定 lr,最后约 10–20% 快速衰减。可以从 stable 段任意点分叉 decay,一次训练得到多个数据量的结果;vs cosine:后者要事先固定总步数。 → 18a
Bits-per-byte — 总 NLL ÷ ln2 ÷ 原文字节数,与 tokenizer 无关的语言建模指标;用来比较不同词表模型的 perplexity。 → 34a
pass@k / avg@k — pass@k:每题采 k 个答案至少一个正确的概率,用 n 个样本的无偏估计 1 − C(n−c,k)/C(n,k);avg@k:每题采 k 次的平均正确率,用来压低采样噪声。 → 34a
Benchmark Contamination(评测污染) — 测试题出现在训练数据中,导致分数虚高。对策:n-gram 重叠检测、按日期滚动的动态题库、私有测试集。 → 34a
LLM-as-a-Judge — 用强模型给开放式回答打分或做两两比较(MT-Bench、AlpacaEval、Arena-Hard);便宜可重复,但有位置、长度、自我偏好偏差。vs Chatbot Arena:后者是真人盲评 + Bradley-Terry / Elo 排名。 → 34a
ViT(Vision Transformer) — 把图片切成固定大小的 patch(如 14×14 或 16×16 像素),每个 patch 线性映射成一个向量,再过双向 attention 的 Transformer Encoder;VLM 的视觉塔。 → 34b
CLIP / SigLIP — 用海量图文对做对比学习的视觉-语言编码器,让匹配的图文向量靠近;产出的 ViT 天然与语言语义对齐,是 VLM 最常用的视觉塔。SigLIP 把 softmax 对比损失换成 sigmoid。 → 34b
Projector(多模态投影层) — 把 ViT 输出映射到 LLM embedding 维度的小网络(LLaVA 用 2 层 MLP),映射后的向量作为图像 token 拼进 prompt。 → 34b
Kernel Fusion / torch.compile / Inductor — Fusion:把多个算子合进一个 kernel,中间结果不回 HBM。torch.compile 用 TorchDynamo 抓图、Inductor 自动融合并生成 Triton kernel;mode="reduce-overhead" 再加 CUDA Graph。 → 40 40a
回到首页 → README
视频对照表:每课 ↔ Karpathy / 李沐 / 关键论文
用法:想「看视频补一课」时查这张表。所有条目都摘自各课末尾的「延伸」小节,没有新增引用;时间点只在课程里给出时才写。「—」表示该课没有直接对应的视频,Part 3 后半和 Part 5 大多如此,这部分直接读论文 / 官方博客更好。
如果只看 5 个视频
从各课引用里挑出覆盖面最大的 5 个,按推荐观看顺序:
Karpathy · The spelled-out intro to neural networks and backpropagation: building micrograd
覆盖 01–04:导数直觉、计算图、链式法则、+= 梯度累加、Neuron → Layer → MLP。看完就懂「训练 = forward + backward + step」。
Karpathy · Let's build GPT: from scratch, in code, spelled out.
覆盖 10–17,以及 19、37 的前置:self-attention 的「数学技巧」、scaled attention、多头、residual、LayerNorm、FFN、generate()。整个 Part 2 的配套。
Karpathy · Let's reproduce GPT-2 (124M)
引用次数最多(07、09、13、15–18、25–30、35、40):weight tying、vocab pad 到 50,304、TF32/BF16(01:28:14、01:39:38)、flash attention(约 02:00:18)、梯度累积(02:34:09)、DDP(02:46:52)、FineWeb-Edu(03:10:21)。从模型到训练工程的一条完整路径。
Karpathy · Deep Dive into LLMs like ChatGPT (3.5 小时)
覆盖 00、17、18、27、31–34:pretraining 数据(00:01:00)、post-training 对话数据(00:59:23 / 01:01:06)、models need tokens to think(01:46:56)、RL(02:14:42)、DeepSeek-R1(02:27:47)、AlphaGo(02:42:07)、RLHF(02:48:26)。这是全课程「全景图」的最佳配套。
李沐 · 「Llama 3.1论文精读」 (分节视频)
覆盖 18、26、27、30、31、32:预训练数据、scaling law、训练 infra(并行)、post-training(SFT + DPO)。一篇真实的工业级报告串起 Part 3–4。
时间再多一点 :Karpathy Intro to Large Language Models (1 小时,「两个文件」比喻,00/36)、Let's build the GPT Tokenizer (06)、李沐「Transformer 论文逐段精读」(10–13、15、37)、「Zero 论文精读」(28–29)。
Part 5(推理 Infra)没有对应视频 :按 career-map 的论文阅读顺序读 FlashAttention → Orca → vLLM/PagedAttention → SGLang → Speculative Decoding → DistServe / Mooncake。
Stanford CS336(长视频版)
CS336 每讲 80 分钟的录像在 YouTube 公开,适合当作每个 Part 的「深入版」。讲次与本课程的逐条对应、以及 5 个作业的最小动手版,见 CS336 对照与动手路线 。
逐课对照
Part 0–1 · 开篇与基础
课
Karpathy
李沐
关键论文 / 资料
00 The Big Picture
Deep Dive into LLMs like ChatGPT (全片,最佳配套);Intro to Large Language Models (「两个文件」「LLM OS」)
InstructGPT 论文精读(SFT → RM → PPO)
InstructGPT (Ouyang 2022);DeepSeek-V3 Technical Report
01 Model = Function
building micrograd (后半段用 MSE 训练小 MLP)
d2l 第 3 章 3.1 线性回归;第 4 章「模型选择、欠拟合和过拟合」
Karpathy, Software 2.0 (2017 博文)
02 Gradient Descent
building micrograd (开头 3x²-4x+5 数值求导;结尾手写 GD 循环)
d2l 第 11 章 优化算法(GD / SGD / 小批量 SGD)
Ruder 2016, An overview of gradient descent optimization algorithms
03 Backprop
building micrograd (整集);makemore Part 4: Becoming a Backprop Ninja
d2l 2.5 自动微分、4.7 前向/反向传播和计算图
Rumelhart, Hinton & Williams 1986;github.com/karpathy/micrograd
04 Neural Network
building micrograd (后半段 Neuron → Layer → MLP)
d2l 第 4 章 4.1 多层感知机
Hendrycks & Gimpel 2016, GELUs
05 Next-Token Prediction
building makemore (bigram 计数 → 神经网络版);makemore Part 2: MLP (复现 Bengio 2003)
d2l 第 8 章「语言模型和数据集」
Bengio et al. 2003, A Neural Probabilistic Language Model ;github.com/karpathy/makemore
06 Tokenization / BPE
Let's build the GPT Tokenizer (BPE、正则预切分、SentencePiece、SolidGoldMagikarp)
d2l 第 14 章「子词嵌入」(BPE)
Sennrich et al. 2016;minbpe;tiktoken
07 Embedding
makemore Part 2: MLP (查找表 C、2 维 embedding 可视化);Let's reproduce GPT-2 (weight tying、vocab pad 到 50,304)
d2l 第 14 章「词嵌入(word2vec)」
Mikolov 2013 (word2vec);Press & Wolf 2017 (weight tying)
08 Softmax / CE / Temperature
building makemore (从计数推出 NLL);makemore Part 3 (初始 loss 应约为 −ln(1/27) = ln 27 ≈ 3.30)
d2l 第 3 章「softmax 回归」
Hinton et al. 2015, Distilling the Knowledge in a Neural Network
09 Training Loop
Let's reproduce GPT-2 (后半段:AdamW 超参、梯度裁剪、warmup + cosine、梯度累积、DDP);makemore Part 2 (train/dev/test、lr sweep)
d2l 第 11 章(动量、Adam、lr 调度);第 4 章 权重衰减、Dropout
AdamW (Loshchilov & Hutter 2019);ZeRO (16 bytes/参数出处)
Part 2 · Transformer
课
Karpathy
李沐
关键论文 / 资料
10 Attention 直觉
Let's build GPT :「the mathematical trick in self-attention」「self-attention」及 attention as communication 的 notes
d2l 第 10 章 10.1、10.3;「Transformer 论文逐段精读」
Bahdanau 2014;Attention Is All You Need 2017
11 Self-Attention 代码
Let's build GPT :「self-attention」「note 6: "scaled" self-attention」「inserting a single self-attention block」
d2l 10.3、10.6;Transformer 精读(Scaled Dot-Product)
AIAYN §3.2.1;PyTorch scaled_dot_product_attention;nanoGPT CausalSelfAttention
12 Multi-Head Attention
Let's build GPT :「multi-headed self-attention」;nanoGPT CausalSelfAttention
d2l 10.5 多头注意力;Transformer 精读
AIAYN §3.2.2;In-context Learning and Induction Heads (2022);Are Sixteen Heads Really Better than One? (2019)
13 Positional Encoding / RoPE
Let's build GPT (position_embedding_table);Let's reproduce GPT-2 (wpe 可视化)
d2l 10.6 自注意力和位置编码;Transformer 精读
RoFormer (2021);ALiBi (2021);EleutherAI Rotary Embeddings 博客
14 Residual & Norm
Let's build GPT :「residual connections」「layernorm (and its relationship to our previous batchnorm)」;makemore Part 3: Activations & Gradients, BatchNorm
d2l 7.5 批量规范化、7.6 ResNet、10.7 Transformer;「ResNet 论文逐段精读」
ResNet (He 2015);Xiong 2020 (Pre-LN);RMSNorm (2019)
15 FFN / SwiGLU
Let's build GPT :「feedforward layers of transformer block」;Let's reproduce GPT-2 (GELU tanh 近似)
d2l 10.7(基于位置的前馈网络);Transformer 精读
Shazeer 2020 GLU Variants ;Geva 2021 (FFN = KV memory);GELU
16 nanoGPT 走读
Let's build GPT (全片);Let's reproduce GPT-2 (加载 HF 权重 → 完整训练)
「GPT,GPT-2,GPT-3 论文精读」;d2l 10.7
nanoGPT model.py;GPT-2 论文 (2019);Press & Wolf 2017
17 解码策略
Let's build GPT (generate);Let's reproduce GPT-2 (top-k=50);Deep Dive (模型是随机 token 采样器)
d2l 9.8 束搜索;GPT 系列精读
Holtzman 2019 (nucleus);CTRL (2019);min-p (2024)
Part 3 · 现代 LLM 架构
课
Karpathy
李沐
关键论文 / 资料
18 Scaling Laws
Let's reproduce GPT-2 (GPT-3 超参与 token 预算);Deep Dive (pretraining 章节)
GPT/GPT-2/GPT-3 精读;Llama 3.1 精读
Kaplan 2020;Chinchilla (Hoffmann 2022);Llama 3 §3.2.1
18a Scaling Laws 实战
—
Llama 3.1 精读(scaling law 章节)
CS336 Lec 3 / 9 / 11、A3;μTransfer (Yang 2022);DeepSeek LLM (2024);MiniCPM (2024)
19 MQA / GQA
Let's build GPT (MHA 实现部分,本课在其上改 KV 头数)
d2l「多头注意力」
Shazeer 2019 (MQA);Ainslie 2023 (GQA)
20 MLA
—(前置:Let's build GPT attention 部分)
—
DeepSeek-V2 §2.1;DeepSeek-V3 §2.1.1;FlashMLA
21 MoE(上)
—(FFN 基础见 Let's build GPT )
—
Mixtral (2024);Switch Transformers (2021);DeepEP
22 MoE(下)
—
—
DeepSeekMoE (2024);Auxiliary-Loss-Free Load Balancing (2024);DeepSeek-V3 §2.1.2;EPLB
23 长上下文
—
—(RoPE 前置见 13)
PI (2023);YaRN (2023);StreamingLLM / Attention Sinks (2023)
24 Linear Attn / SSM / Hybrid
—(makemore 仓库 RNN/GRU 实现有助理解递推)
d2l「循环神经网络」「现代循环神经网络(GRU/LSTM)」
Transformers are RNNs (2020);Mamba (2023);Gated DeltaNet (2024);MiniMax-01;Kimi Linear;Qwen3-Next 博客
25 MTP 与训练技巧
Let's reproduce GPT-2 (wte 与 lm_head 共享)
—
Gloeckle 2024 (MTP);DeepSeek-V3 §2.2;Kimi K2 (MuonClip);Gemma 2 / Gemma 3
26 解剖现代模型
Let's reproduce GPT-2 (从 HF 权重对照结构,读 config 的起点)
Llama 3.1 精读
Llama 3;Qwen3;DeepSeek-V3;gpt-oss Model Card;Kimi K2
Part 4 · 训练流水线
课
Karpathy
李沐
关键论文 / 资料
27 预训练数据
Deep Dive 「pretraining data (internet)」00:01:00 ;Let's reproduce GPT-2 「datasets used in GPT-2, GPT-3, FineWeb (EDU)」03:10:21
「Llama 3.1论文精读 · 2. 预训练数据」
FineWeb (2024);Llama 3 §3.1;datatrove
28 Mixed Precision
Let's reproduce GPT-2 「Tensor Cores, timing the code, TF32 precision」01:28:14 、「float16, gradient scalers, bfloat16」01:39:38
「Zero 论文精读」(混合精度 Adam 显存构成)
Mixed Precision Training (2017);FP8 Formats for Deep Learning (2022);DeepSeek-V3 §3.3
29 DP / ZeRO / FSDP
Let's reproduce GPT-2 「gradient accumulation」02:34:09 、「distributed data parallel (DDP)」02:46:52
「Zero 论文精读」;d2l 12.5 多 GPU 训练、12.7 参数服务器
ZeRO (2019);PyTorch FSDP (2023)
30 TP / PP / EP / CP
—(Let's reproduce GPT-2 的 DDP 一节可帮助理解 rank / process group)
「Megatron LM 论文精读」「GPipe 论文精读」;「Llama 3.1论文精读 · 4. 训练infra」
Megatron-LM (2019);PTD-P / interleaved 1F1B (2021);DeepSeek-V3 §3.2
31 SFT / Chat Template
Deep Dive 「pretraining to post-training」00:59:23 、「post-training data (conversations)」01:01:06
InstructGPT 精读;「Llama 3.1论文精读 · 5. 模型训练过程」
InstructGPT;LIMA (2023);HF「Chat Templates」文档
32 RLHF / PPO / DPO
Deep Dive 「reinforcement learning from human feedback (RLHF)」02:48:26
InstructGPT 精读;Llama 3.1 精读 · 5(DPO 部分)
InstructGPT;DPO (2023);PPO (2017)
33 推理模型 / GRPO
Deep Dive 「models need tokens to think」01:46:56 、「reinforcement learning」02:14:42 、「DeepSeek-R1」02:27:47 、「AlphaGo」02:42:07
暂无;可回看 InstructGPT 精读的 PPO 流程
DeepSeek-R1 (2025);DeepSeekMath (GRPO 出处);HybridFlow (verl)
34 LoRA / PEFT
暂无专门视频;Deep Dive 「pretraining to post-training」可作背景
d2l 13.2「微调」
LoRA (2021);QLoRA (2023);S-LoRA (2023)
34a Evaluation
Deep Dive (末尾:如何跟踪模型进展)
—
CS336 Lec 12;HELM (2022);HumanEval / pass@k (2021);MT-Bench & Chatbot Arena (2023)
34b 多模态
—
ViT 逐段精读;CLIP 逐段精读
CS336 Lec 17;ViT (2020);CLIP (2021);LLaVA (2023);Qwen2-VL (2024)
Part 5 · 推理与 AI Infra
课
Karpathy
李沐
关键论文 / 资料
35 GPU 速成
Let's reproduce GPT-2 第二部分「Let's make it fast」:GPU、Tensor Cores、TF32 01:28:14 ;llm.c 仓库
d2l 第 12 章「计算性能」(12.4 硬件)
NVIDIA Hopper Tuning Guide ;H100 Architecture 白皮书;Horace He, Making Deep Learning Go Brrrr
36 Prefill vs Decode
Intro to Large Language Models (开头「两个文件」:推理 = 反复读参数文件)
d2l 第 12 章
Roofline (Williams 2009);Databricks LLM Inference Performance Engineering (MBU);LLM Inference Unveiled (2024)
37 KV Cache
Let's build GPT 的 generate()(无 KV Cache,每步整段重算,对照理解)
「Transformer 论文逐段精读」(decoder 自回归与 mask)
PagedAttention 论文 §3;GQA;vllm/v1/core/kv_cache_utils.py
38 PagedAttention
—
—(复习 OS 虚拟内存 / 分页)
PagedAttention (SOSP 2023);vLLM 博客 (2023);vllm/v1/core/block_pool.py
39 Continuous Batching
—
—
Orca (OSDI 2022);Sarathi-Serve (OSDI 2024);vllm/v1/core/sched/scheduler.py
40 FlashAttention
Let's reproduce GPT-2 「flash attention」约 02:00:18 (换成 F.scaled_dot_product_attention)
—(d2l 第 12 章作背景)
FlashAttention (2022);FlashAttention-2 (2023);FlashAttention-3 (2024)
40a Kernel 实战
Let's reproduce GPT-2 (torch.compile 提速部分)
—
CS336 Lec 5–6、A2;Triton 官方教程;Horace He Go Brrrr ;PyTorch 2 (2024)
41 Prefix Caching / RadixAttention
—
—
SGLang (NeurIPS 2024);vLLM Automatic Prefix Caching 设计文档;radix_cache.py
42 Speculative Decoding
—
—
Leviathan 2022;Chen 2023 (Speculative Sampling);Medusa;EAGLE / EAGLE-2 / EAGLE-3;vllm/v1/spec_decode/、rejection_sampler.py
43 Quantization
—
—
GPTQ;AWQ;SmoothQuant;NVIDIA NVFP4 博客;gpt-oss Model Card
44 Distributed Inference
—
—(Megatron 相关可参考论文精读系列)
DistServe;Splitwise;Mooncake;DeepSeek Day 6 推理系统概览;LMSYS 96×H100 PD 分离博客;DeepEP
45 vLLM 架构
—
—
vLLM 博客 vLLM V1: A Major Upgrade (2025-01-27);PagedAttention 论文;vllm/v1/ 源码
46 SGLang 架构
—
—
SGLang 论文;XGrammar (2024);LMSYS 博客 Compressed FSM (2024-02-05)、SGLang v0.4 (2024-12-04)
47 指标与选型
—
—
DistServe(goodput 定义);Hao AI Lab 博客 Throughput is Not All You Need ;vllm bench serve / sglang.benchmark.serving 源码;TensorRT-LLM、LMDeploy、llama.cpp 仓库
按视频反查(这个视频能配哪几课)
视频
对应课程
Karpathy · building micrograd
01, 02, 03, 04
Karpathy · building makemore
05, 08
Karpathy · makemore Part 2: MLP
05, 07, 09
Karpathy · makemore Part 3: Activations & Gradients, BatchNorm
08, 14
Karpathy · makemore Part 4: Becoming a Backprop Ninja
03
Karpathy · Let's build GPT
10, 11, 12, 13, 14, 15, 16, 17, 19, 37(20、21 作前置)
Karpathy · Let's build the GPT Tokenizer
06
Karpathy · Let's reproduce GPT-2 (124M)
07, 09, 13, 15, 16, 17, 18, 25, 26, 27, 28, 29, 30, 35, 40
Karpathy · Deep Dive into LLMs like ChatGPT
00, 17, 18, 27, 31, 32, 33, 34
Karpathy · Intro to Large Language Models
00, 36
李沐 · Transformer 论文逐段精读
10, 11, 12, 13, 15, 37
李沐 · GPT,GPT-2,GPT-3 论文精读
16, 17, 18
李沐 · InstructGPT 论文精读
00, 31, 32, 33
李沐 · Llama 3.1论文精读
18, 26, 27, 30, 31, 32
李沐 · Zero 论文精读
28, 29
李沐 · Megatron LM / GPipe 论文精读
30
李沐 · ResNet 论文逐段精读
14
李沐 · 《动手学深度学习》
01–17, 19, 24, 29, 34, 35, 36, 40
回到首页 → README