🧠 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 分钟 打开下一课读完;或者去 闪卡 刷 10 张
2 分钟 只读任意一课的「一句话」和「💬 能聊的要点」
想先定方向 先读 00 全景图 和 AI Infra 知识地图与转岗路线
遇到不懂的词 查 术语表,每个词都链回对应课程
想看视频 视频对照表:每课 ↔ Karpathy / 李沐
想亲手验证 CS336 对照与动手路线:每个 Part 配一个几小时的小实验

推荐路径

阅读方式: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. 每一环在干什么(用后端工程师熟悉的话说)

3. 训练 vs 推理:两种完全不同的工程问题

Training Inference / Serving
目标 把 loss 降下去 低延迟、高吞吐、低成本
形态 一个超大离线 batch job 在线服务,请求随机到达
瓶颈 算力(FLOPs)+ 多卡通信 显存容量 + 显存带宽
关键词 AdamW、BF16/FP8、ZeRO/FSDP、TP/PP/EP KV Cache、Continuous Batching、Prefix Caching、投机解码、量化
成本特征 一次性巨额 持续、与用户量成正比

表里的关键词现在不认识没关系,每个都有专门的课。先建立两个词的直觉:

一个好记的说法(来自 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. 推荐阅读路径

代码片段

用十几行伪代码把整条链路串起来——后面每一课都在展开其中某一行:

# ===== 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 避免重复计算

工程视角 / 真实世界

💬 能聊的要点

  1. 「LLM 的训练分两大阶段:pretraining 用海量无标注文本做 next-token prediction,得到只会续写的 base model;post-training 用 SFT + 偏好/RL 把它变成助手或推理模型。算力绝大部分花在 pretraining。」
  2. 「训练是 compute-bound 的离线大作业,瓶颈在 FLOPs 和多卡通信;推理是在线服务,decode 阶段是 memory-bound 的,瓶颈在显存容量和带宽,所以 vLLM/SGLang 的核心优化都围绕 KV Cache 展开。」
  3. 「post-training 和 pretraining 在代码上几乎是同一个循环,区别在数据和目标函数:SFT 还是交叉熵,RLHF/GRPO 换成了奖励信号。」

常见误区

自测(心算/口答即可)

  1. Base model 和 Instruct model 的区别是什么?分别是哪个阶段的产物?
    答案Base model 是 pretraining 的产物,只会续写文档;Instruct model 是在 base model 上做 post-training(SFT + RLHF/DPO 等)得到的,会按指令回答。
  2. 「训练主要受算力限制,推理 decode 主要受显存带宽限制」——为什么会有这种差异?
    答案训练时一次 forward 处理大量 token(大 batch × 长序列),每读一次权重能做很多计算;decode 时每个请求每步只生成 1 个 token,每读一遍全部权重只做很少的计算,所以卡在搬数据上。第 36 课会用 Roofline 细讲。
  3. KV Cache 属于链路的哪一环?
    答案Serving / Inference。训练时整段序列并行计算,不需要跨步缓存;推理时逐 token 生成,缓存历史 token 的 K/V 避免重复计算。

延伸

下一课 → 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 来自数据)

符号约定(全课通用):

Karpathy 在 2017 年的博文 Software 2.0 里就这么描述:神经网络是一种新型软件,它的「源代码」是数据集 + 架构,「编译」过程就是训练,「编译产物」就是权重。

2. 三个要素

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:

  1. 训练时数据集是固定的,不会变;
  2. 唯一在变的是 θ,每换一组 θ 就能算出一个 loss 值;
  3. 所以 L(θ) 是参数空间里的一个「地形」:2 个参数(w, b)时,横轴 w、纵轴 b、高度是 loss,是一个三维曲面;80 亿参数时就是 80 亿维的地形。

训练 = 在这个地形里找低点(下一课:Gradient Descent)。

4. 拟合的两种失败

类比:学生背答案 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

工程视角 / 真实世界

💬 能聊的要点

  1. 「模型就是一个参数化函数,训练就是在参数空间里优化 loss;架构决定假设空间,数据和 loss 决定在里面找哪个点。」
  2. 「8B 模型 BF16 权重大约 16GB,这个换算——参数数 × 每参数字节数——是后面算显存、选 GPU、做量化的起点。」
  3. 「真正要看的是 validation loss 而不是 train loss,因为目标是泛化;LLM 预训练通常数据只过一遍左右,所以 train/val loss 往往贴得很近。」

常见误区

自测(心算/口答即可)

  1. 一个 70B 参数的模型用 BF16 存,权重大约多大?用 INT4 呢?
    答案BF16:70B × 2 字节 ≈ 140GB;INT4:70B × 0.5 字节 ≈ 35GB(实际略大,因为量化还要存 scale 等元数据)。
  2. 「Loss 是参数的函数」这句话怎么理解?
    答案训练时数据是固定的,唯一在变的是参数 θ;每换一组 θ 就得到一个 loss 值,所以 L(θ) 是参数空间上的一个曲面,训练就是在这个曲面上找低点。
  3. 训练 loss 持续下降,验证 loss 先降后升,说明什么?
    答案开始过拟合:模型在记训练集的细节/噪声,泛化能力变差。应该早停(early stopping)、加数据或加正则。

延伸

下一课 → 02 · Gradient Descent:沿着坡往下走

02 · Gradient Descent:沿着坡往下走

⏱ 10 分钟 | 前置:01 · Model = Function | 难度:★☆☆

一句话

**Gradient(梯度)**告诉你「每个参数往哪个方向动一点,loss 上升最快」;**Gradient Descent(梯度下降)**就是反复朝梯度的反方向迈一小步:θ ← θ - lr × ∇L(θ)。

为什么要关心

上一课说训练就是在 80 亿维的地形里找低点,但你不可能枚举或随机试——维度太高了。梯度下降是今天几乎所有神经网络训练的唯一实用方法;Adam、AdamW、学习率调度、梯度裁剪、梯度累积这些名词,全都是在这一行公式上的改良或工程补丁。

核心直觉

1. 蒙眼下山

想象你蒙着眼站在山坡上,想走到谷底。你看不见全貌,但可以用脚感受脚下的坡度。策略:感受坡往哪边最陡地向上,就朝反方向迈一步,然后再感受、再迈步。

 loss
   │ ●                               ● = 当前参数位置
   │   ╲                             每一步:看斜率,往下挪
   │    ●
   │      ╲
   │       ●
   │         ╲___●___╱               ← 谷底:斜率 ≈ 0,停下
   └──────────────────────── θ

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。

  1. 算梯度:∂L/∂w ≈ -27.35,∂L/∂b = -9.5(可以用「轻推」法验证:w 从 0.5 推到 0.501,loss 下降约 0.027,比值 ≈ -27.3)
  2. 读数:两个都是负数 → 调大 w 和 b 都能让 loss 下降。这和常识一致:当前预测 [0.5, 1, 1.5, 2] 全部低于真实值 [3.1, 4.9, 7.2, 8.8],当然该往上调
  3. w 的读数绝对值更大 → loss 对 w 更敏感(因为 w 要乘上 x,x 最大到 4)
  4. 取 lr = 0.01 走一步:w = 0.5 - 0.01 × (-27.35) = 0.7735,b = 0 - 0.01 × (-9.5) = 0.095
  5. 新的 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)数据,用这批数据的梯度近似全量梯度。类比:想知道全表某列的平均值,不做全表扫描,而是随机抽样几千行估计——估计有误差,但便宜得多,而且平均来看不偏(统计上叫「无偏估计」)。

今天说「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 附近

工程视角 / 真实世界

💬 能聊的要点

  1. 「梯度下降的核心就一行 θ -= lr * grad,梯度只提供局部方向,所以 lr 决定了稳定性;大模型训练的很多工程手段——warmup、梯度裁剪、AdamW——都是为了让这一步走得稳。」
  2. 「实际用的是 mini-batch SGD:用一小批样本的梯度近似全量梯度,用噪声换速度;batch size 和 lr 需要一起调。」
  3. 「数值求导要跑 N 次 forward,而 backprop 只需要一次 forward 加一次 backward,这是深度学习能扩展到十亿参数的前提。」

常见误区

自测(心算/口答即可)

  1. 当前参数 w = 1.0,dL/dw = 4,lr = 0.1,更新后 w 是多少?
    答案w = 1.0 - 0.1 × 4 = 0.6。
  2. 一个 8B 参数的模型,梯度张量有多少个数?
    答案也是 8B 个——梯度与参数一一对应、形状相同。BF16 存储约 16GB,这也是训练比推理费显存的原因之一。
  3. 为什么大模型训练用 mini-batch 而不是全量数据算梯度?
    答案全量数据有万亿 token,算一次梯度的成本无法接受;mini-batch 梯度是全量梯度的无偏估计,每步便宜得多,能在同样算力下走更多步。

延伸

下一课 → 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):

  1. a → e 的传动比:e = a × b,a 每加 1,e 变化 b = -3 倍
  2. e → d 的传动比:d = e + c,1 倍(加法原样传递)
  3. d → L 的传动比:L = d × f,f = -2 倍
  4. 连乘:(-3) × 1 × (-2) = 6,这就是表里的 a.grad = 6
  5. 用上一课的「轻推」法核对: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 次同样大小的矩阵乘:

  1. 梯度对 x 是多少?(要继续往更前面的层传)→ 1 次矩阵乘
  2. 梯度对 W 是多少?(这是参数,要拿去更新)→ 1 次矩阵乘

所以 backward ≈ 2 × forward。FLOPs(Floating Point Operations,浮点运算次数)是衡量计算量的单位,一次乘法 + 一次加法算 2 FLOPs。于是:

例: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()

工程视角 / 真实世界

💬 能聊的要点

  1. 「Backprop 就是在计算图上反向应用链式法则:每个算子只实现自己的局部导数,一次 backward 就拿到所有参数的梯度,代价约为 forward 的 2 倍,所以训练约 6N FLOPs/token,推理约 2N。」
  2. 「训练比推理费显存,除了梯度和优化器状态,还因为 backward 需要 forward 的中间 activations;activation checkpointing 用重算换显存。」
  3. 「PyTorch 的梯度是累加的,这既是要 zero_grad 的原因,也是 gradient accumulation 能用小 batch 模拟大 batch 的原因。」

常见误区

自测(心算/口答即可)

  1. 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。
  2. 一个 70B 模型训练 2T tokens,训练总 FLOPs 大约多少?
    答案6 × 70e9 × 2e12 = 8.4e23 FLOPs。
  3. 为什么 b = a + a 时,micrograd 必须用 += 累加梯度?
    答案a 通过两条路径影响 b,多元链式法则要求把各条路径的贡献相加:db/da = 1 + 1 = 2。用 = 会让第二次写入覆盖第一次,得到错误的 1。

延伸

下一课 → 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 的每一维是一个神经元的输出。

整个网络的参数绝大部分在这些 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

工程视角 / 真实世界

💬 能聊的要点

  1. 「神经网络就是 Linear 和非线性交替堆叠:没有非线性,多层会塌缩成一层;Linear 占了几乎全部参数和 FLOPs,所以一个 dense 模型 forward 约 2N FLOPs/token。」
  2. 「Transformer 的 FFN 就是一个先升维再降维的 MLP,激活函数从 ReLU 演化到 GELU 再到 SwiGLU;FFN 通常占每层参数的 2/3 以上。」
  3. 「逐元素算子 FLOPs 很少但吃带宽,所以工程上要把它们和 GEMM 做 kernel fusion。」

常见误区

自测(心算/口答即可)

  1. nn.Linear(4096, 4096, bias=False) 有多少参数?处理 1 个 token 约多少 FLOPs?
    答案4096² ≈ 16.8M 参数;约 2 × 16.8M ≈ 33.6M FLOPs。
  2. 为什么去掉非线性后两层 MLP 学不会 XOR?
    答案去掉非线性后整个网络等价于一个线性函数,只能画一条直线(超平面)分割输入空间,而 XOR 不是线性可分的。
  3. (512×4096) @ (4096×14336) 这次 matmul 的 FLOPs 大约是多少?
    答案2 × 512 × 4096 × 14336 ≈ 6.0e10,即约 60 GFLOPs。

延伸

下一课 → 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))

工程视角 / 真实世界

💬 能聊的要点

  1. 「LLM 的训练目标就是 next-token prediction,通过概率链式法则,它等价于给整段文本建模;它是自监督的,所以能吃下互联网规模的数据。」
  2. 「训练时 teacher forcing,一条长度 T 的序列一次 forward 就得到 T 个预测,高度并行;推理时每个 token 依赖上一个的采样结果,必须串行——这是推理优化的根本矛盾。」
  3. 「makemore 里 bigram 计数表和一层神经网络收敛到同样的 loss,说明神经网络只是更可扩展的『统计方法』;从 bigram 到 Transformer,变的只是利用上下文的方式。」

常见误区

自测(心算/口答即可)

  1. 一条 1024 token 的训练序列,提供了多少个 next-token 预测样本?
    答案1024 个(每个位置预测它的下一个 token,target 是输入右移一位)。
  2. 27 个字符上完全均匀的猜测,loss 是多少?bigram 做到约 2.45 意味着什么?
    答案ln 27 ≈ 3.30。2.45 低于它,说明模型学到了字符之间的统计规律;exp(2.45) ≈ 11.6,相当于每步在约 12 个候选里犹豫,而不是 27 个。
  3. 为什么计数表方法不能扩展到长上下文?
    答案看前 k 个 token,表的行数是 Vᵏ,指数爆炸,而且绝大多数组合在数据中从未出现;神经网络通过 Embedding 共享参数、泛化到没见过的组合。

延伸

下一课 → 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:

  1. 先拆成字节:h e l p(4 个 token)
  2. 套规则 1:he l p → [256, l, p]
  3. 套规则 2:hel p → [257, p]
  4. 规则 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. 两个工程细节

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 带来的怪毛病

代码片段

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

工程视角 / 真实世界

💬 能聊的要点

  1. 「BPE 是子词切分的折中:词表可控、永不 OOV、常见词一个 token;byte-level BPE 从 256 个字节起步,贪心地合并最高频的相邻对。」
  2. 「词表大小是个取舍:变大会让序列变短、KV Cache 和 Attention 更省,但 embedding/LM head 变大、softmax 更贵。Llama 从 2 代的 32K 扩到 3 代的 128K 就是为了多语言和压缩率。」
  3. 「很多 LLM 的怪问题——数字母、算术、glitch token、多语言更贵——根源都在 tokenization,因为模型根本看不到字符。」

常见误区

自测(心算/口答即可)

  1. GPT-2 的 50,257 是怎么来的?
    答案256 个基础字节 token + 50,000 次 BPE 合并 + 1 个 <|endoftext|> 特殊 token。
  2. 同一段文本,用 32K 词表和 128K 词表 tokenizer,哪个 token 数更少?这对推理有什么影响?
    答案通常 128K 词表更少(更多长片段被合并成单个 token)。token 越少,prefill 计算、KV Cache 显存、decode 步数都越少,等效上下文更长。
  3. 为什么 LLM 数不准 strawberry 里有几个 r?
    答案模型输入的是 token id 而非字母;这个词被切成少数几个子词 token,字母层面的信息没有直接暴露给模型,只能靠记忆间接推断。

延伸

下一课 → 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:

  1. 如果两个 token 后面常跟相似的东西(如 a 和 e 后面都常跟辅音),它们要产生相似的预测;
  2. 后面的网络对相近的输入向量给出相近的输出;
  3. 所以梯度会把这两个 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

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

工程视角 / 真实世界

💬 能聊的要点

  1. 「Embedding 本质是一个 V×d 的查找表,等价于 one-hot 乘矩阵,所以可以端到端用梯度训练;语义相近的 token 会在训练中自然聚到一起。」
  2. 「LM head 是 embedding 的镜像,小模型常做 weight tying 省参数——GPT-2 small 的 embedding 就占了约 31% 参数;Llama 3 8B 不 tie,embedding+head 约 1B。」
  3. 「token embedding 是静态的,上下文相关的表示来自之后的 Attention 层;RAG 的文本 embedding 模型则是另一回事。」

常见误区

自测(心算/口答即可)

  1. 词表 128,256、d = 4096,embedding 在 BF16 下占多少显存?
    答案128,256 × 4096 ≈ 5.25 亿参数 × 2 字节 ≈ 1.05GB;如果 LM head 不共享,再加约 1.05GB。
  2. 一个 batch 中只出现了 500 个不同的 token,这一步 embedding 矩阵有几行会收到非零梯度?
    答案只有这 500 行(按行索引,未被查到的行梯度为 0)。但「梯度为 0」不等于「参数不动」:用 AdamW 时,weight decay 会让所有行都缩小一点,而以前被用过的行还带着历史动量 m(第 09 课),也会继续被更新。
  3. Weight tying 为什么对小模型更有价值?
    答案小模型 d 小、层数少,V × d 占总参数比例高(GPT-2 small 约 31%),共享能显著减少参数;大模型中这个比例小,收益有限。

延伸

下一课 → 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 同时解决两个问题——

  1. 连乘变连加:一段文本的概率是每个 token 概率的连乘(第 05 课),几千个小于 1 的数相乘会下溢成 0;取 log 后变成加法,数值稳定,还能按 token 取平均。
  2. 惩罚不对称: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:

这是一个非常实用的 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()

工程视角 / 真实世界

💬 能聊的要点

  1. 「语言模型的 loss 就是正确 token 的负对数概率,最小化它等价于最大似然;随机初始化时 loss 应接近 ln V,GPT-2 约 10.8,这是最基本的 sanity check。」
  2. 「Temperature 只是在 softmax 前把 logits 除以 T,不改变候选排序,只改变分布的尖锐程度;T→0 等价于 greedy。」
  3. 「大词表下 logits 张量是训练显存的隐藏大户,(B·T)×V 在 FP32 下可以到几十 GB,所以有 fused cross-entropy 这类分块计算的 kernel。」

常见误区

自测(心算/口答即可)

  1. 模型给正确 token 的概率是 0.5,这个位置的 loss 是多少?
    答案-ln 0.5 ≈ 0.69。
  2. 你训练一个 V = 32,000 的模型,第 0 步 loss 是 25,正常吗?
    答案不正常。期望约 ln 32000 ≈ 10.4。25 说明初始化让 logits 过大、模型对错误答案过度自信,应缩小输出层初始化。
  3. logits 为 [3, 3, 0],temperature 从 1 调到 0.1,前两个 token 的相对概率会变吗?
    答案不会,两者 logits 相同,永远各占一半;第三个 token 的概率会趋近 0。Temperature 只改变不同 logits 之间的相对差距。

延伸

下一课 → 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、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

6. Overfitting 与正则化

代码片段

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

工程视角 / 真实世界

💬 能聊的要点

  1. 「LLM 训练标配是 AdamW(β2=0.95、weight decay 0.1)+ warmup + cosine decay + 梯度裁剪 1.0;Adam 为每个参数维护一阶和二阶矩,实现逐参数自适应步长。」
  2. 「混合精度 + Adam 每个参数要约 16 字节(2+2+4+4+4),7B 模型光状态就 112GB,这是 ZeRO/FSDP 把优化器状态、梯度、参数切到多卡的直接动机。」
  3. 「预训练一般只跑约 1 个 epoch,基本不会过拟合,train/val loss 贴在一起;SFT 数据小、多 epoch,才是过拟合的高发区。」

常见误区

自测(心算/口答即可)

  1. 每卡 micro-batch 为 8 条 × 4096 token,梯度累积 4 步,64 张卡数据并行,每步 global batch 是多少 token?
    答案8 × 4096 × 4 × 64 = 8,388,608 ≈ 8.4M tokens。
  2. 用混合精度 + AdamW 训练 13B 模型,不算 activations 大约需要多少显存?
    答案13B × 16 bytes ≈ 208GB,至少需要把状态切到 3 张以上的 80GB 卡上。
  3. 为什么要 warmup?
    答案训练初期参数随机、梯度大且方向不稳,Adam 的二阶矩估计也还不准,直接用峰值 lr 容易发散;先用小 lr 过渡,让优化器统计量稳定下来。

延伸

下一课 → 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

每个 token 同时扮演三个角色:自己发出一个 Query,也向别人暴露 Key 和 Value。Q/K/V 都是由同一个 token 的 Embedding 乘三个不同的矩阵得到,这三个矩阵是学出来的——模型自己决定「什么该匹配什么」。

🧩 难点拆解:Q/K/V 到底是什么,为什么要三个投影

先直觉:一个 token 的 Embedding 向量 x 里什么信息都有(词义、词性、单复数……)。但「拿去匹配」和「被取走」需要的信息不一样:

所以用三个矩阵 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}

由此推出几个关键性质:

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                 # 按相似度加权平均

工程视角 / 真实世界

💬 能聊的要点

  1. 「Attention 本质是一个内容寻址的软查表:Q 和 K 算相似度决定权重,V 是被取走的内容;Q/K/V 投影是学出来的,所以模型自己学会『什么该关注什么』。」
  2. 「相对 RNN,Attention 把任意两个 token 之间的路径长度降到 O(1) 且训练可并行,代价是计算和显存随序列长度平方增长——长上下文优化基本都在跟这个 T² 较劲。」
  3. 「Attention 本身是置换不变(permutation-invariant,打乱输入顺序、输出只是跟着打乱)的,不知道顺序,所以 Transformer 必须额外注入位置信息;GPT 的 causal mask 则保证每个位置只能看过去,这也是 KV Cache 能成立的前提。」

常见误区

自测(心算/口答即可)

  1. 为什么 causal mask 用 -inf 而不是 0 来屏蔽?
    答案mask 加在 softmax 之前的分数上。填 0 的话 exp(0)=1,未来位置仍会拿到权重;填 -inf 则 exp(-inf)=0,softmax 后权重恰好为 0,且其余权重仍自动归一化到 1。
  2. 序列长度从 4K 变成 32K,单层 attention 分数矩阵的元素数变成原来的几倍?
    答案长度 ×8,元素数 ×64(平方关系)。
  3. 一个 batch 里的两条样本会互相 attend 吗?
    答案不会。Attention 在每条样本的 (T,T) 矩阵内部计算,batch 维度只是并行。(推理系统把多个请求拼进同一个 batch 时,也要靠 mask/变长 kernel 保证它们互不可见。)

延伸

下一课 → 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)?三个原因,一步步看:

  1. 相似度会是对称的:x_A·x_B = x_B·x_A,A 对 B 的关注度必然等于 B 对 A。但语言里关系是有方向的:代词要找名词,不代表名词也要找代词。用 q_A·k_B = (x_A W_q)·(x_B W_k) 后,两个方向用的是不同矩阵,自然不对称。
  2. 自己总是最像自己:x·x 是向量长度的平方,往往最大,于是每个 token 最关注的都是自己。投影后 q_i·k_i 不再有这个保证。
  3. 「匹配」和「被取的内容」被绑死: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 就是把波动缩回和维度无关的水平。

因果链:

  1. 假设初始化时 q、k 的每个分量独立、均值 0、方差(数值偏离均值的平方的平均,衡量「波动」)为 1。
  2. 每一项 q_i·k_i 的方差也是 1;d_k 个独立项相加,方差就相加 → 点积方差 = d_k,标准差 = √d_k。
  3. d_k=64 时标准差 = 8,分数经常出现 ±10、±20 这样的值。
  4. softmax 里是 exp(分数):exp(18) ≈ 6.6 千万,exp(2) ≈ 7.4,差距被指数放大 → 输出接近 one-hot(一个 1、其余全 0)。
  5. softmax 输出接近 one-hot 时,输入稍微变一点输出几乎不变 → 梯度≈0,这叫饱和;梯度为 0,参数就不更新。
  6. 除以 √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 的分数已经影响了前两个的比例。

为什么这让训练能并行:

  1. 一条长度 T 的序列要产生 T 个训练样本(「看 t1 猜 t2」「看 t1..t2 猜 t3」……)。
  2. 不加 mask,每个样本得单独跑一次前向(输入长度不同)。
  3. 加了下三角 mask 后,第 i 行天然只看到前 i 个 token,正好等价于「第 i 个样本」。
  4. 所以一次前向、一次矩阵乘就同时算完 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)
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) 矩阵。

工程视角 / 真实世界

💬 能聊的要点

  1. 「除以 √d_k 是因为 QKᵀ 点积的方差随 d_k 线性增长,不缩放 softmax 会饱和成 one-hot、梯度消失;这是一个纯数值稳定性的设计。」
  2. 「Causal mask 让 decoder 在一次前向里并行训练序列上所有位置的 next-token 预测,这是 Transformer 训练效率远高于 RNN 的关键;推理 decode 时每步只有一个 Query,反而不需要 mask。」
  3. 「朴素实现要物化 B×H×T×T 的分数矩阵,显存 O(T²);生产上用 scaled_dot_product_attention / FlashAttention 分块计算,数学结果一样,但不写回完整矩阵。」

常见误区

自测(心算/口答即可)

  1. B=8, T=1024, 单头 head_size=64,att 张量有多少个元素?如果是 fp32 占多少显存?
    答案8×1024×1024 ≈ 8.4M 个元素,×4 字节 ≈ 32 MiB。这只是一层一个 head;12 层×12 head 就要乘 144——这就是为什么需要 FlashAttention。
  2. 为什么 BERT 不需要 causal mask,GPT 需要?
    答案BERT 的训练目标是完形填空(Masked LM),输入里被遮住的词本身已经替换成 [MASK],双向看上下文不会泄漏答案;GPT 的目标是预测下一个 token,看到未来就等于看到答案。
  3. head_size 从 64 改成 128,不做 √d_k 缩放的话,初始化时分数的标准差大约变成多少?
    答案方差 = d_k = 128,标准差 ≈ 11.3(原来是 8)。

延伸

下一课 → 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)

关键点:

🧩 难点拆解: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. 为什么「多个小头」比「一个大头」好

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)

工程视角 / 真实世界

💬 能聊的要点

  1. 「MHA 不是把注意力复制 h 份,而是把 d_model 切成 h 个子空间,参数量始终是 4d²、FLOPs 也不变;收益是一个位置能同时维持多种独立的关注分布。」
  2. 「head 在 attention 计算里互不通信、天然并行,实现上就是把 head 当成 batch 维;所以 Tensor Parallel 最自然的切法就是按 head 分卡,最后靠 W_O 之后的 all-reduce 汇总。」
  3. 「KV Cache 的大小正比于 n_kv_head × head_dim,MHA 下每个 query head 都有自己的 KV;MQA/GQA 就是在保留 query head 数的同时减少 KV head,直接把 KV Cache 缩小 n_head/n_kv_head 倍。」

常见误区

自测(心算/口答即可)

  1. GPT-2 small(d=768,12 层)所有 attention 层的权重矩阵参数量大约多少(不计 bias)?
    答案每层 4d² = 4 × 589,824 ≈ 2.36M,×12 层 ≈ 28.3M。
  2. 把 d_model=4096 的模型从 32 个 head 改成 64 个 head,head_dim 变成多少?QKᵀ 的总 FLOPs 变吗?
    答案head_dim 从 128 变成 64;总 FLOPs 不变(h × d_head 恒等于 d_model)。
  3. 为什么 view 之前要 .contiguous()?
    答案transpose 只改 stride(每一维前进 1 格要在内存里跳过多少个元素)不搬数据,得到的张量内存不连续;view 要求连续内存才能重新解释形状,所以先 contiguous() 拷贝一次(或者用 reshape,它会在需要时自动拷贝)。

延伸

下一课 → 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 层输入处加一次

两者效果在原论文里差不多。共同缺点:

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 要用几十种转速组合来避免歧义;而且训练时没见过的大转角(超长距离),模型未必处理得好——这就是长上下文外推的难点。

于是:

直觉上:高频的维度对(θ 大)转得快,对近距离敏感;低频的维度对转得极慢,携带长距离信息。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 ...

工程视角 / 真实世界

模型 位置编码 备注
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 以支持更长上下文

💬 能聊的要点

  1. 「Attention 是置换不变的,必须注入位置;GPT-2 用可学习的绝对位置向量,只在输入加一次,训练长度之外完全没法外推。」
  2. 「RoPE 把 Q/K 每两维当成平面向量按 位置×频率 旋转,点积只依赖角度差,所以用绝对位置实现、得到相对位置效果,且零参数、不碰 V。」
  3. 「RoPE 的 base(rope_theta)决定最低频维度的波长,Llama 3 把它从 1 万调到 50 万就是为了让长距离的位置仍可区分;各种长上下文外推方法(PI/NTK/YaRN)本质都在改这些频率。」

常见误区

自测(心算/口答即可)

  1. nanoGPT 加载 GPT-2 权重后,输入第 1025 个 token 会发生什么?
    答案wpe 只有 1024 行,索引越界直接报错;nanoGPT 在 forward 里用 assert t <= block_size 挡住,generate 里则把上下文裁到最近的 block_size 个 token。
  2. RoPE 下,位置 (5, 8) 的一对 q/k 与位置 (105, 108) 的同样一对 q/k,attention 分数一样吗?
    答案一样(假设 q、k 向量内容相同):RoPE 的分数只依赖相对距离 8−5 = 108−105 = 3。
  3. 为什么 V 不需要旋转?
    答案位置信息只需要影响「看谁」(Q·K 的打分),Value 是被取走的内容本身;而且 RoPE 的相对位置性质是靠 q 和 k 的旋转相互抵消得到的,V 不参与点积,旋转它没有这个效果。

延伸

下一课 → 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
               └────────────────┘ └────────────────┘         主路上只有加法

因果链:

  1. Pre-Norm 展开后 x_L = x_0 + f_1(LN(x_0)) + f_2(LN(x_1)) + ...,和上面的 🧩 一样,主路是纯加法,梯度直达。
  2. Post-Norm 的主路每层都要穿过一次 LN。LN 会除以当前向量的标准差(例如 stream 的尺度是 5,就除以 5),梯度回传时也被相应缩放;叠几十层,各层收到的梯度大小就很不均匀。
  3. Xiong et al. (2020) 分析发现,Post-Norm 在初始化时靠近输出层的梯度偏大,必须配合 learning rate warmup(训练开头几千步把学习率从很小慢慢升到目标值)才能稳定训练。
  4. 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

工程视角 / 真实世界

💬 能聊的要点

  1. 「Residual 让每层只学增量,并给梯度一条不经过矩阵乘的直达路径;把 x 看成各层读写的 residual stream,d_model 就是这条总线的带宽。」
  2. 「Pre-Norm 把 Norm 放进分支里,residual 路径保持纯加法,深层训练稳定、对 warmup 不那么敏感;代价是 stream 尺度随深度增长,需要一个 final norm。GPT-2 之后的 LLM 基本都是 Pre-Norm。」
  3. 「RMSNorm 去掉减均值和 bias,只按均方根缩放,效果几乎不掉但更便宜;推理框架常把 residual add 和 RMSNorm 融合成一个 kernel 来省显存带宽。」

常见误区

自测(心算/口答即可)

  1. d_model=4096 的一个 RMSNorm 有多少参数?同维度 LayerNorm 呢?
    答案RMSNorm 只有 γ:4096 个;LayerNorm 有 γ 和 β:8192 个。
  2. 为什么 decode 时用 BatchNorm 会出问题?
    答案BatchNorm 的统计量依赖 batch 内其他样本,推理时只能用训练期的 running statistics;而 LN/RMSNorm 对每个 token 独立计算,训练和推理行为一致,与 batch 里有哪些请求无关。
  3. 一个 12 层的 GPT-2 small,nanoGPT 对 c_proj 初始化的 std 是多少?
    答案0.02 / √24 ≈ 0.0041。

延伸

下一课 → 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

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):

  1. 普通 FFN:2 个矩阵 × 4096 × 16384 = 8d² ≈ 134M。
  2. SwiGLU 要保持 134M,3 个矩阵平分:每个 ≈ 44.7M,中间维度 = 44.7M / 4096 ≈ 10923(= 8/3 × 4096)。
  3. 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))  # 逐元素相乘后降维

工程视角 / 真实世界

模型 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

💬 能聊的要点

  1. 「Attention 负责 token 间通信,FFN 负责逐 token 的特征变换;FFN 是 position-wise 的,本质是一个大 GEMM,在标准 MHA block 里占约 2/3 参数,用了 GQA 后占比更高。」
  2. 「SwiGLU 用一路 SiLU 激活作为门、逐元素乘到另一路上,比 GELU 效果更好;为了参数量持平,中间维度从 4d 缩到约 8d/3,所以 Llama-2-7B 是 11008 而不是 16384。」
  3. 「FFN 是 TP 和 MoE 的天然切入点:列切 + 行切只需一次 all-reduce;把它换成多个 expert 加路由就是 MoE。」

常见误区

自测(心算/口答即可)

  1. 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。
  2. d=4096,若 SwiGLU 的中间维度仍用 4d=16384,一层 FFN 参数比 8d² 多多少?
    答案3 × 4096 × 16384 = 12d² ≈ 201M,比 8d² ≈ 134M 多 50%。
  3. 为什么说 FFN 对序列长度「无感」?
    答案它对每个 token 独立计算,计算量与 token 数线性相关,不存在 attention 那样的 T² 项;实现上把 (B,T) 展平成一个维度做矩阵乘即可。

延伸

下一课 → 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):

  1. Attention:W_Q、W_K、W_V 各 d×d(多头只是切分,不加参数),W_O 也是 d×d → 4d²。
  2. MLP:c_fc 是 d×4d,c_proj 是 4d×d → 4d² + 4d² = 8d²。
  3. 一个 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 模型要按上一课的方法重算。

几个观察:

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. 显存与算力的粗算

代码片段

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

参数量验证(在 Python 里一行搞定):

model = GPT.from_pretrained('gpt2')
print(sum(p.numel() for p in model.parameters()))   # 124439808(共享权重只算一次)

工程视角 / 真实世界

💬 能聊的要点

  1. 「Decoder-only 的 GPT 就是 embedding、N 个 Pre-Norm Block、final norm 和 lm_head;Block 内外 shape 恒为 (B,T,d),这也是 residual stream 的宽度。」
  2. 「非 embedding 参数 ≈ 12·L·d²:attention 4d²、MLP 8d²。GPT-2 small 算出来 85M 加上 38.6M 的 embedding 就是 124M;GPT-3 用这个公式能算到约 174B。」
  3. 「训练 FLOPs ≈ 6ND、推理每 token ≈ 2N,是估算训练预算和推理成本最常用的两个公式;小模型里 embedding 占比很高,所以 GPT-2 用 weight tying 省了 38.6M 参数(约为不共享时总量的 1/4)。」

常见误区

自测(心算/口答即可)

  1. 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 量级一致。
  2. 用 10B tokens 训练 GPT-2 124M,总训练 FLOPs 约多少?
    答案6 × 124M × 10B ≈ 7.4 × 10¹⁸ FLOPs。
  3. 训练时 B=8、T=1024,logits 张量在 fp32 下多大?
    答案8 × 1024 × 50257 × 4 字节 ≈ 1.65 GB——比模型权重本身(0.5GB)还大,这就是很多框架做「分块计算 cross-entropy」的原因。

延伸

下一课 → 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:确定性的选择

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%,加起来也不小,一旦采中就「跑偏」,而且错误会被后续生成放大。解决办法是只在「靠谱的候选」里采样。

分布尖:  [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 家族

这些惩罚可能误伤:代码、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)

工程视角 / 真实世界

💬 能聊的要点

  1. 「模型只给 logits,生成质量很大程度由解码决定:temperature 调尖锐度不改排序,top-k/top-p/min-p 截掉长尾避免一步采错满盘皆输,penalty 抑制复读。」
  2. 「top-p 比 top-k 好在候选集随分布自适应——模型很确定时只剩一两个 token,不确定时放宽;min-p 则按最大概率的比例设阈值,高温下更稳。」
  3. 「所有这些策略在实现上都是『把 logit 置 -inf 再 softmax 采样』,structured output 也是同一套机制;对推理系统来说,sampling 是每个 decode step 都要跑的 GPU 阶段,大 vocab 下值得专门优化。」

常见误区

自测(心算/口答即可)

  1. 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]。
  2. min_p=0.1,最大概率为 0.6,阈值是多少?
    答案0.1 × 0.6 = 0.06,概率低于 0.06 的 token 全部去掉。
  3. temperature 从 1 调到 0.5,会改变 greedy 的输出吗?
    答案不会。temperature 只缩放 logits,不改变排序,argmax 不变;它只影响采样时的分布形状。

延伸

下一课 → 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 堆到远超这个比例。

为什么要关心

核心直觉

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

工程视角 / 真实世界

💬 能聊的要点

  1. 「Scaling Laws 的价值不是『越大越好』这句口号,而是 loss 可预测:用小一两个数量级的实验拟合幂律,就能在花大钱之前定下模型规模、数据量和超参。」
  2. 「Chinchilla 说 compute-optimal 大约 20 tokens/参数,但它只优化训练成本;现代模型把推理成本计入,普遍 over-train,比如 Llama-3-8B 喂了 15T tokens,接近 2000 tokens/参数。」
  3. 「训练 FLOPs 约等于 6ND,其中 MoE 的 N 取激活参数;配合 GPU 峰值和 MFU,就能粗算出一个训练任务需要多少 GPU·天。」

常见误区

自测(心算/口答即可)

  1. 一个 70B dense 模型训 2T tokens,大约多少 FLOPs?
    答案6 × 70e9 × 2e12 = 8.4 × 10^23 FLOPs。
  2. 同样算力下,为什么 Meta 不按 Chinchilla 比例把 8B 的算力拿去训一个 ~77B 模型?
    答案8B 模型推理便宜近 10 倍(显存、延迟、吞吐都好),能在单卡部署;多花的训练算力是一次性成本,推理成本是持续成本。
  3. MoE 模型 671B 总参数、37B 激活,训练算力估算用哪个 N?
    答案用激活参数 37B,因为每个 token 只经过这些参数的计算。

延伸

下一课 → 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 随算力外推,一条训练曲线复用出多个数据量的结果)。

为什么要关心

核心直觉

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 都是这个流程。两个细节决定成败:

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 只需记三条:

效果:在约 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

5. 形状超参:有经验区间,不必死磕

Kaplan 2020 发现:总参数固定时,深宽比在很宽的范围内对 loss 影响很小。所以形状超参更多是「对齐硬件 + 沿用经验」(CS336 Lecture 3 专门统计了一批开源模型):

超参常见取值Llama-3-8B / 70B 实例
FFN 宽度 / d_modelGELU 约 4;SwiGLU 约 8/3 ~ 3.5(第 15 课)14336 / 4096 = 3.5;28672 / 8192 = 3.5
head_dim64 ~ 128,n_head × head_dim ≈ d_model128 / 128
d_model / n_layers(宽深比)约 100 ~ 2004096/32 = 128;8192/80 ≈ 102
vocab早期 32K~50K,现在 128K~256K(多语言、代码)128,256
weight decay / grad clip0.1 / 1.00.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:

  1. 拟合出 N_opt ∝ C^0.46(理论值 β/(α+β) = 0.28/0.62 ≈ 0.45,说明流程本身没问题);
  2. 外推到 1e24 FLOPs,得到约 47B 参数、3.5T tokens,约 75 tokens/参数——比「20 倍」大,因为这套公式拟合出的最优比例本来就随算力漂移(第 18 课提到它算出的最优点和论文另两种方法有出入);
  3. 如果小实验的 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

工程视角 / 真实世界

💬 能聊的要点

  1. 「拟合 scaling law 用 IsoFLOP:每个小预算扫 N 取谷底,再在 log-log 上拟合 N_opt 对 C 的直线外推。关键是每个小实验都要训到位,Kaplan 和 Chinchilla 结论不同,很大程度就是调度长度和参数口径的问题。」
  2. 「标准参数化下,模型变宽最优 lr 会变小;μP 按宽度缩放各层 lr 和初始化,让小模型调好的超参直接迁移。不用 μP 的团队,就给 lr 和 batch 也拟合幂律,比如 DeepSeek LLM。」
  3. 「WSD 调度用恒定 lr 跑主干,最后 10–20% 快速衰减;从主干任意点分叉做 decay,一次训练就能得到多个数据量的结果,很适合做 data scaling 实验。」

常见误区

自测(心算/口答即可)

  1. IsoFLOP 曲线的横轴、纵轴分别是什么?谷底代表什么?
    答案固定算力 C,横轴是模型大小 N(log),纵轴是训练完的 loss;谷底是这个算力下的 compute-optimal 模型大小 N_opt,对应的 D = C/6N。
  2. 为什么 Kaplan 2020 会低估数据的重要性?说出两个原因。
    答案① 所有 run 用同一条 cosine 调度长度,数据少的 run 没衰减完,loss 偏高,显得「多训数据收益不大」;② 只数非 embedding 参数且实验规模偏小,口径差异让拟合指数偏移。
  3. 按 DeepSeek LLM 的公式,算力从 1e22 增加到 1e24(100 倍),最优 lr 变为原来的多少?
    答案100^(-0.125) = 10^(-0.25) ≈ 0.56,约降到原来的一半多一点。

延伸

下一课 → 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 模型的默认配置。

为什么要关心

核心直觉

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?

4. 历史脉络

代码片段

# 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)

工程视角 / 真实世界

💬 能聊的要点

  1. 「GQA 是 MHA 和 MQA 的折中:Query 保持多头,K/V 分组共享,KV Cache 缩小 n_head/n_kv_head 倍,质量几乎不掉。」
  2. 「Llama-3-8B 每 token KV Cache 是 2×32 层×8 头×128×2B = 128KiB,8K 上下文一条请求就是 1GiB;如果是 MHA 要 4 倍。」
  3. 「KV head 数通常取 8,一个重要原因是和单机 8 卡 TP 对齐;MQA 在 TP 下反而要复制 KV,省不下显存。」

常见误区

自测(心算/口答即可)

  1. Qwen3-8B:36 层、8 个 KV head、head_dim 128、BF16,每 token KV Cache 多大?
    答案2×36×8×128×2 = 147,456 B = 144 KiB。
  2. 一张 80GB 卡跑 Llama-3-8B(权重约 16GB),假设剩 60GB 全给 KV Cache,最多容纳多少 token?
    答案60 GiB ÷ 128 KiB ≈ 49 万 token,例如约 60 条 8K 请求(粗算,忽略激活与碎片)。
  3. 为什么 GQA 对 prefill 的速度帮助不大、对 decode 帮助大?
    答案Prefill 是 compute-bound,主要花在矩阵乘上;decode 每步都要读全部 KV Cache,是 memory-bound,KV 变小直接减少读取字节数。

延伸

下一课 → 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。

为什么要关心

核心直觉

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]。

账算下来(单层、单头数 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 的解法:把位置信息拆出去单独走一条小通道。

这就是 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

工程视角 / 真实世界

💬 能聊的要点

  1. 「GQA 靠减少 KV 头数省显存,MLA 靠低秩压缩:只缓存 512 维 latent 加 64 维共享 RoPE key,DeepSeek-V3 每 token 约 69 KB,比 Llama-3-70B 的 GQA 还小好几倍。」
  2. 「MLA 能不解压就算 attention,靠的是 weight absorption:把 W_UK 并进 query 侧、W_UV 并进输出投影,decode 时形态上等价于一个 KV 维度 576 的 MQA。」
  3. 「RoPE 的旋转矩阵依赖位置,会夹在 query 和 W_UK 之间、且随每个历史位置变化,破坏 absorption,所以 MLA 把位置信息拆成单独的 64 维 decoupled RoPE key。」

常见误区

自测(心算/口答即可)

  1. DeepSeek-V3 的 config 里 num_key_value_heads: 128,为什么不能用它来算 KV Cache?
    答案这个字段是为了兼容 HF 的通用接口,MLA 实际缓存的是 kv_lora_rank + qk_rope_head_dim = 576 维/层/token,与头数无关。
  2. 为什么 RoPE 部分的 key 要所有头共享,而不是每头一份?
    答案它需要单独缓存(无法压进 latent),每头一份就是 128×64 维,会把省下的显存吃回去;共享后只需 64 维。
  3. 用 FP8 存 MLA 的 cache,DeepSeek-V3 每 token 大约多大?
    答案576×61×1 B ≈ 34 KiB(忽略 scale 等元数据)。

延伸

下一课 → 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 计算量」解耦。

为什么要关心

核心直觉

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

类比:一家医院有 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]。

  1. 取最大的 2 个:E1(2.0)和 E3(1.0),E2、E4 对这个 token 完全不计算。
  2. 对这 2 个分数做 softmax 得到权重:E1 得 e^2 / (e^2 + e^1) ≈ 0.73,E3 得 0.27。
  3. 输出 = 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. 小历史

代码片段

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

工程视角 / 真实世界

💬 能聊的要点

  1. 「MoE 把 FFN 拆成多个 Expert,每 token 由 Router 选 top-k 个,本质是解耦总参数和每 token 计算量:Mixtral 总参 47B,但每 token 只算 13B。」
  2. 「MoE 是用显存和通信换计算:所有 Expert 必须常驻显存,EP 下每层多两次 all-to-all,所以它对 infra 的要求比 dense 高得多。」
  3. 「MoE 推理吃 batch:batch 小时每个 Expert 只分到零星几个 token,变成 memory-bound 的 GEMV,吞吐很差;这是 DeepSeek 用大规模 EP 部署的原因。」

常见误区

自测(心算/口答即可)

  1. 一个 MoE 有 64 个 Expert、top-8,单个 Expert 参数和 dense FFN 的 1/8 一样大,它的每 token FFN 计算量是 dense 的多少?
    答案8 × 1/8 = 1 倍,和 dense 相同,但 FFN 总参数是 dense 的 8 倍。
  2. EP=8、每层 MoE 需要几次 all-to-all(forward)?
    答案两次:dispatch(token 送到 Expert 所在卡)和 combine(结果送回)。
  3. 为什么 MoE 训练用 6ND 估算 FLOPs 时 N 用激活参数?
    答案每个 token 只经过被选中的 Expert,未被选中的 Expert 对这个 token 不产生计算。

延伸

下一课 → 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。

为什么要关心

核心直觉

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] 中选若干个

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:

好处:均衡压力不再通过梯度干扰主任务。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、α 先不管:

这 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())   # 过载的降、欠载的升

工程视角 / 真实世界

各家 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

💬 能聊的要点

  1. 「传统 MoE 靠 aux loss 做均衡,但 loss 系数大了伤效果、小了压不住;DeepSeek-V3 改成给每个 Expert 加一个只影响 top-k 选择、不影响权重的 bias,按负载在线加减,基本不干扰主任务。」
  2. 「DeepSeekMoE 的两个设计:细粒度 Expert 让组合更灵活、Expert 更专精;Shared Expert 承担通用知识,减少路由 Expert 之间的冗余。V3 是 1 个 Shared + 256 选 8。」
  3. 「负载均衡本质上是系统问题:EP 下最忙的卡决定整层耗时,所以有 capacity factor、node-limited routing,以及推理时的冗余 Expert(EPLB)。」

常见误区

自测(心算/口答即可)

  1. 1024 个 token、64 个 Expert、top-2、capacity factor 1.0,每个 Expert 容量多少?
    答案1.0 × 1024 × 2 / 64 = 32 个 token。
  2. 为什么 bias 只用于选择、不用于加权?
    答案如果 bias 进入加权,它会直接改变模型输出、影响梯度,就又变成了一种干扰主任务的均衡手段;只影响选择则只改变「分工」,不扭曲被选中 Expert 的贡献。
  3. DeepSeek-V3 为什么限制每个 token 最多发往 4 个节点?
    答案跨节点 IB 带宽远低于节点内 NVLink,限制节点数可以控制 all-to-all 的跨节点流量,让通信能和计算重叠。

延伸

下一课 → 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 则解释了为什么只保留窗口会崩、以及怎么修。

为什么要关心

核心直觉

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 倍

怎么读这张表:

  1. i=0:4K 内转了 652 圈,所有角度都见过。推到 32K 只是多转几圈,没有新角度——不需要改。但 PI 把它也放慢 8 倍,相邻两个 token 的角度差从 1 弧度变成 0.125,「这是前一个词还是前两个词」变难分辨。
  2. i=63:4K 内只转了 7.5% 圈。推到 32K 时要转到 60%,这些角度模型从没见过 → OOD。三种方法都把它放慢 8 倍,32K 时又回到熟悉的 7.5% 范围。
  3. 中间维度: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):
■                     ■
■ ■                   ■ ■
■ ■ ■                 ■ ■ ■
■ ■ ■ ■               · ■ ■ ■
■ ■ ■ ■ ■             · · ■ ■ ■

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(温度修正)

工程视角 / 真实世界

💬 能聊的要点

  1. 「RoPE 外推失败的根源是低频维度在训练长度内没转满一圈,推理时遇到没见过的角度;PI 是整体压缩位置,NTK-aware 改 base,YaRN 按维度分段只插值低频并加温度修正,是目前主流。」
  2. 「看 config 的 rope_scaling 就知道模型怎么扩的上下文:DeepSeek-V3 是 YaRN 4K×40,gpt-oss 是 YaRN 4K×32,Llama 3.1 是自己的 llama3 分段缩放 8K×8。」
  3. 「Sliding window 把 KV Cache 限定在窗口内,但需要保留 attention sink——开头几个 token 充当 softmax 的垃圾桶;gpt-oss 直接给每个头加了可学习的 sink logit。」

常见误区

自测(心算/口答即可)

  1. 原始长度 4K、要扩到 32K,PI 的缩放因子是多少?NTK-aware 对高频维度做了什么?
    答案s = 8,所有位置除以 8;NTK-aware 对高频维度几乎不改变,主要拉伸低频维度。
  2. Mistral 7B 32 层、W=4096,为什么理论感受野约 131K?
    答案每一层能把信息向前传 W 个位置,32 层叠加 32 × 4096 ≈ 131K。
  3. 用纯滑窗做无限长流式生成,为什么要额外保留前 4 个 token?
    答案它们是 attention sink,承接大量「无处可去」的注意力;移出 cache 后注意力分布剧变,模型输出崩溃。

延伸

下一课 → 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。

为什么要关心

核心直觉

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 两者结合

🧩 难点拆解:Delta rule——从「累加」到「覆盖」

直觉:朴素版本像一个只会 += 的 KV 存储,同一个 key 写两次就把两个 value 加在一起了。Delta rule 把它变成「先读旧值、再写差值」,效果上接近 put(k, v) 覆盖。

小例子(接上面的 S = [[5,3],[7,1]]):现在要把 key [1,0] 的值更新为 v3 = [9,9]。

现实中 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」

短序列时 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 无关

工程视角 / 真实世界

💬 能聊的要点

  1. 「Linear attention 去掉 softmax 后可以先算 KᵀV,得到一个 d×d 的定长 state,推理像 RNN 一样每步 O(1),彻底消除随长度增长的 KV Cache。」
  2. 「纯线性模型的问题是定长 state 有损,精确召回差;Mamba 引入输入相关的遗忘门,DeltaNet 引入先擦后写的 delta rule,Gated DeltaNet 两者兼有。」
  3. 「2025 年主流是 hybrid:Qwen3-Next 是 3 层 Gated DeltaNet 配 1 层 gated attention,MiniMax-01 是 7:1;但 MiniMax-M2 又退回了 full attention,说明这条路还没有定论。」

常见误区

自测(心算/口答即可)

  1. 一个纯 linear attention 层,head_dim 128,32 个头,处理 1M token 后 state 多大?
    答案32 × 128 × 128 = 524,288 个元素,与 token 数无关,BF16 约 1 MiB。
  2. Delta rule 相比朴素 linear attention 的「只加」更新,解决了什么问题?
    答案同一个 key 反复写入时,朴素版本会叠加产生干扰;delta rule 先读出旧值、只写差值,实现覆盖更新,记忆更干净。
  3. 为什么 hybrid 模型在短对话场景下显存优势不明显?
    答案线性层的 state 是固定开销(Qwen3-Next 约 36 MiB/序列),短序列时 KV Cache 本来就小,定长 state 可能比省下的 KV Cache 还大。

延伸

下一课 → 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 里的一个字段。

为什么要关心

核心直觉

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

为什么有用:

  1. 训练信号更密:每个位置多一个监督目标,逼模型「提前规划」。V3 的 MTP loss 权重 λ 在前 10T tokens 为 0.3,之后 4.8T 为 0.1。
  2. 推理白送一个 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。

一句话:训练时 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

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. 其他常见「小改动」速查

代码片段

先看本课主角 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

再看本课的几个「小改动」在代码里的位置:

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 对象

工程视角 / 真实世界

💬 能聊的要点

  1. 「DeepSeek-V3 的 MTP 是串行模块:用主模型 hidden state 加上真实下一个 token 的 embedding 再预测下下个 token;训练时增加监督密度,推理时可当 draft 做投机解码,接受率 85%–90%,约 1.8 倍 TPS。」
  2. 「训练稳定性的核心问题是 attention logits 爆炸:主流解法是 QK-Norm(Qwen3、Gemma 3),Gemma 2 用 tanh soft-capping,Kimi K2 因为 MLA 没法做 QK-Norm,改用 QK-Clip 直接缩放权重。」
  3. 「Tied embedding 在小模型上很值:Qwen3-0.6B 的 embedding 约占总参 1/4,所以 0.6B/4B 共享、8B 以上不共享。」

常见误区

自测(心算/口答即可)

  1. 为什么 DeepSeek-V3 的 MTP 模块要输入「真实的下一个 token 的 embedding」,而不是只用 h_t?
    答案保持因果链:预测 t+2 时条件包含 t+1 的真实 token,和推理时逐 token 生成的条件一致,比并行头只靠 h_t 猜更准。
  2. Gemma 2 的 final logit cap 是 30,某个 logit 原值是 3 和 300,soft-cap 后大约是多少?
    答案30·tanh(0.1) ≈ 2.99;30·tanh(10) ≈ 30。小值几乎不变,大值被压到上限附近。
  3. vocab 128K、hidden 4096,tie 能省多少参数?
    答案128,256 × 4096 ≈ 525M 参数(BF16 约 1GB)。

延伸

下一课 → 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 分钟内说出一个新模型「长什么样、要多少显存」。

为什么要关心

核心直觉

1. 大对照表(数据来自各官方 config.json 与技术报告)

怎么读这两张表:每一列是一个模型,每一行是一个结构参数(括号里是 config.json 的字段名)。

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. 从表里能读出的趋势

  1. 骨架趋同:全部是 decoder-only、Pre-Norm RMSNorm、RoPE、SwiGLU(gpt-oss 的 SwiGLU 带 clamp),基本都不共享 embedding(大模型)。
  2. KV head 收敛到 8 或更少:Llama 全系 8;Qwen3 MoE 只有 4;DeepSeek/Kimi 用 MLA 走到 576 维/层。
  3. MoE 越来越细:Expert 中间维 1536–2880,远小于 dense 的 hidden×3;激活 4–8 个。Kimi K2 的思路是「更多 Expert(384)、更少 attention 头(64)」——头数减半降低长上下文推理成本。
  4. 激活比例越来越低:Qwen3 约 9%,V3 约 5.5%,K2 约 3%,gpt-oss-120b 约 4%。
  5. 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)

工程视角 / 真实世界

💬 能聊的要点

  1. 「现代开源 LLM 的骨架基本统一为 Pre-Norm RMSNorm + RoPE + SwiGLU 的 decoder-only;差异主要在 KV 压缩方式(GQA、MLA、SWA)、MoE 粒度、上下文扩展和稳定性技巧。」
  2. 「拿到一个新模型我会先看 config:层数、hidden、Q/KV 头数和 head_dim 算 KV Cache;Expert 数、top-k、moe_intermediate_size 算总参和激活参;quantization_config 和 torch_dtype 算权重显存;rope_scaling 判断真实上下文。」
  3. 「趋势是 MoE 越来越稀疏:V3 激活约 5.5%,K2 约 3%,gpt-oss-120b 约 4%;这让训练更便宜,但把压力转移到显存容量和 all-to-all 通信上,是 infra 最需要关注的变化。」

常见误区

自测(心算/口答即可)

  1. Llama 3.1 405B 每 token KV Cache 多大?128K 上下文一条请求呢?
    答案2 × 126 × 8 × 128 × 2 B = 516,096 B ≈ 504 KiB;× 131,072 ≈ 63 GiB。
  2. Qwen3-235B-A22B 的 Q 头数 × head_dim 是多少?和 hidden 一样吗?
    答案64 × 128 = 8192,而 hidden 是 4096,不相等;q_proj 是 4096→8192,o_proj 是 8192→4096。
  3. 看到 config 里有 kv_lora_rank: 512 和 n_routed_experts: 384,你能推断出什么?
    答案这是 MLA + 细粒度 MoE 的 DeepSeek-V3 系架构(例如 Kimi K2);KV Cache 按 576 维/层算,部署需要 MLA kernel 和 EP。

延伸

下一课 → 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」。原因:

  1. Chinchilla 只优化训练算力;
  2. 但模型训完要部署,推理成本按参数量算,要跑几亿次;
  3. 所以多花训练算力把小模型「喂饱」,换来一个更强、推理更便宜的小模型,总账更划算。

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(去重):为什么、怎么做

网页重复极多(转载、模板页、镜像站)。重复数据会让模型背诵、浪费算力、还会让评测集泄漏更严重。

4. Quality classifier:用模型筛数据

规则只能去掉明显的垃圾,「是否有信息量」需要模型判断。常见做法:

  1. 用强 LLM 给少量样本打分(FineWeb-Edu:Llama-3-70B-Instruct 给约 46 万网页打 0–5 分「教育价值」)
  2. 用这些标注训一个便宜的小分类器(embedding 模型 + 线性头,或 fastText)
  3. 对全量数据推理、按阈值过滤(FineWeb-Edu 取 ≥3,15T → 1.3T)

类比:这就像风控里「人工审几十万单 → 训一个轻量模型 → 线上全量打分」——贵的判断只做一次,便宜的模型做规模化。

Llama 3 也是这套路:用 Llama 2 标注数据,训 fastText 和 RoBERTa 类分类器筛网页。本质是用上一代模型给下一代模型筛数据。

5. Data mixture(配比)

网页只是一部分,还要混入代码、数学、论文、书、多语言。配比决定模型「偏科」方向。Llama 3 报告的最终 mix 约为:50% 通用知识、25% 数学与推理、17% 代码、8% 多语言。

配比怎么定?

代码片段

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 相同 → 候选重复对 → 连通分量里只留一篇

工程视角 / 真实世界

💬 能聊的要点

  1. 「预训练数据本质是一条超大规模 ETL:抽正文、语言识别、启发式过滤、MinHash 去重、模型打分过滤、按领域配比;FineWeb 从 96 个 CC snapshot 得到 15T token,再用分类器筛出 1.3T 的 FineWeb-Edu。」
  2. 「去重不是越狠越好——FineWeb 发现全局去重反而留下更差的数据,最后改为 snapshot 内去重;被广泛转载的内容往往是好内容。」
  3. 「Llama 3 8B 训了 15T token,是 Chinchilla 最优的几十倍,因为推理成本跟参数量走,过度训练小模型在部署上更划算;配比和训练末期 annealing 上采样高质量数据对 benchmark 影响很大。」

常见误区

自测(心算/口答即可)

  1. 8B 模型按 Chinchilla「20 token/参数」应训多少 token?Llama 3 8B 实际训了约多少倍?
    答案约 160B;实际 15T,约 90 多倍。
  2. MinHash signature 为什么能在不两两比较全文的情况下估计相似度?
    答案对随机 hash 函数,两个集合 min-hash 相等的概率等于它们的 Jaccard 相似度;多个 hash 取平均即可估计,而 LSH 分桶让只有桶 key 相同的文档才进入比较。
  3. 用 LLM 直接给 15T token 的网页打分可行吗?业界怎么做?
    答案太贵。做法是用大模型只标注几十万样本,训练一个小分类器(embedding+线性头/fastText),再对全量数据推理。

延伸

下一课 → 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。

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):

  1. 某个真实梯度是 1e-8,FP16 里直接是 0。
  2. 把 loss 乘 S。求导是线性的(d(S·L)/dw = S · dL/dw),所以每个梯度都自动放大 S 倍:1e-8 × 65536 ≈ 6.6e-4,FP16 能表示了。
  3. 反向结束后先把梯度转成 FP32,再除以 S:6.6e-4 / 65536 = 1e-8,原值找回。
  4. 代价:大梯度也被放大了。若某个梯度本来是 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)

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):

代码片段

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()

工程视角 / 真实世界

💬 能聊的要点

  1. 「BF16 和 FP32 一样有 8 位指数,所以范围相同、不需要 loss scaling;代价是只有 7 位尾数,因此 master weights 和 Adam 状态必须留在 FP32,否则小的更新会被舍入吞掉。」
  2. 「混合精度 Adam 每参数约 16 字节:2 字节 BF16 权重、2 字节梯度、12 字节 FP32 master+m+v;7B 模型光这些就 112GB,这就是为什么全参训练 7B 就得上 ZeRO/FSDP。」
  3. 「FP8 训练的关键是细粒度 scaling——DeepSeek-V3 对 activation 按 1×128、weight 按 128×128 分块量化,并定期把累加提升到 FP32;Blackwell 的 MXFP8 把这种 block scaling 做进了硬件。」

常见误区

自测(心算/口答即可)

  1. 13B 模型用 BF16 混合精度 Adam 全参训练,不算 activation,需要多少显存?
    答案13e9 × 16 B ≈ 208 GB。
  2. 为什么 FP16 训练需要 loss scaling,BF16 不需要?
    答案FP16 只有 5 位指数,小梯度会 underflow 成 0,需要先放大再还原;BF16 有 8 位指数,范围与 FP32 相同。
  3. 推理时只加载 BF16 权重,7B 模型要多少显存(不算 KV Cache)?和训练差几倍?
    答案约 14 GB;训练的 112 GB 是它的 8 倍。

延伸

下一课 → 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    ← 各自更新,结果一致

🧩 难点拆解:ring all-reduce 为什么每卡只发 ~2 倍数据

先直觉:最朴素的做法是找一个「中心节点」(参数服务器)收齐 N 份梯度再广播回去——中心节点要收 N×D 字节,卡越多越堵。Ring 的做法像「接力传水桶」:N 张卡连成环,每卡只和左右邻居说话,把数据切成 N 块,每一步每卡同时「发一块给右边、收一块自左边」,所有链路同时满载,没有热点。

再代数字(N = 4,每卡梯度 D = 1 GB,切成 4 块,每块 250 MB):

  1. Reduce-scatter 阶段:3 步(N−1 步)。每步每卡发 250 MB,收到的块加到自己对应块上。3 步后,每卡恰好有一块是 4 张卡之和。每卡发了 3 × 250 = 750 MB。
  2. All-gather 阶段:再 3 步,把各自那块「完整的和」沿环传一圈,每卡又发 750 MB。
  3. 每卡总计发送 2 × (N−1)/N × D = 1.5 GB。换成 N = 64:2 × 63/64 × 1 GB ≈ 1.97 GB。卡数涨 16 倍,每卡流量几乎不变,上限就是 2D。
  4. 按节点间每卡 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):

  1. 前向/反向:用完整 BF16 参数算自己 micro-batch 的梯度。
  2. Reduce-scatter 梯度:GPU1 只收到下标 250M–500M 这一段的「4 卡梯度之和」,其余段的梯度算完就可以扔。
  3. 用这段梯度 + 自己的 FP32 master/m/v 更新这 250M 个参数。
  4. 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(不随时间变)

一句话总结:FSDP 用「按层借、用完还、提前借下一层」把完整模型的显存占用摊成了时间上的流水,峰值只多出一两层。

4. DP 的极限

代码片段

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

工程视角 / 真实世界

💬 能聊的要点

  1. 「ZeRO-1/2/3 依次切 optimizer states、gradients、parameters;1 和 2 的通信量和 DDP 一样(all-reduce 本来就是 reduce-scatter + all-gather),ZeRO-3 多一次参数 all-gather,大约 1.5 倍。」
  2. 「FSDP 就是 PyTorch 原生的 ZeRO-3:按层 all-gather 参数、算完即丢、prefetch 下一层做 overlap;HYBRID_SHARD 在节点内切、节点间复制,用来适配 NVLink 和 IB 的带宽差。」
  3. 「ZeRO 解决的是冗余状态,不解决 activation 和单层计算过大的问题;所以大模型训练里 DP/FSDP 通常是最外层,内层还要叠 TP/PP。」

常见误区

自测(心算/口答即可)

  1. 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,放不下。
  2. ZeRO-2 中梯度同步用的是什么集合通信?为什么不用 all-reduce?
    答案Reduce-scatter:每卡只负责更新 1/N 参数,只需要那 1/N 的梯度之和,不必每卡都拿到全量梯度。
  3. 为什么 DDP 可以在反向还没结束时就开始通信?
    答案反向从最后一层往前算,后面层的梯度先就绪;DDP 按 bucket 分组,一个 bucket 的梯度齐了就发起 all-reduce,与前面层的计算 overlap。

延伸

下一课 → 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 各持有一半权重

🧩 难点拆解:为什么「先列切、再行切」只要一次 all-reduce

先直觉:矩阵乘 X·A 有两种切法——

GeLU 是非线性的:GeLU(a+b) ≠ GeLU(a)+GeLU(b),所以 GeLU 之前必须拿到真值。第一刀用列切,恰好让每卡拿到「真值的一部分列」,GeLU 本地做;GeLU 的输出正好是按列切开的,又正好是第二个矩阵行切所需要的输入。两刀咬合,中间零通信,只在最后求一次和。

再代数字(2 卡,1 个 token,d=2,4d 用 4 列;为了心算用 ReLU 代替 GeLU):

一句话总结:列切的输出「完整但分段」,正好喂给行切;非线性夹在中间也不用通信,一个 MLP 只需结尾一次 all-reduce。

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 格时间):

  1. p = 4,m = 4:总时长 = m + p − 1 = 7 格,每个 stage 真正干活 4 格,空等 3 格 → bubble = 3/7 ≈ 43%。
  2. p = 4,m = 32:总时长 35 格,空等仍是 3 格 → 3/35 ≈ 8.6%。
  3. 通式: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 调度压缩。

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):

  1. 每卡有 1000 × 2 = 2000 份「token → 专家」请求。
  2. 若路由均匀,每卡发给每张卡约 500 份,其中 1500 份要离开本卡 → 1500 × 14 KB ≈ 21 MB。
  3. combine 再回传同样多。每层 2 次,DeepSeek-V3 有 58 个 MoE 层(前 3 层是 dense),一次前向就是上百次 shuffle。
  4. 如果某张卡的专家很「热」,收到 3200 份而不是 2000 份,它的计算时间多 60%,其余 3 张卡都要等它——这就是负载均衡直接变成吞吐的原因。

类比不成立之处:MapReduce shuffle 一个 job 做一次、可以落盘;这里每层都做、同步阻塞、全在显存里,必须和计算重叠才不拖垮 MFU。

一句话总结:EP 的 all-to-all 是「按专家 id 做的 shuffle」,一去一回,最慢的那张卡决定整层速度。

4. Context Parallel(CP):切长序列

128K 序列的 activation 一张卡放不下。CP 沿序列维切分,每卡持有一段 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):

  1. 每卡持 32K token,这些 token 的 activation 只占 1/4 显存。
  2. Ring 方式:4 步,每步用本地 32K 个 Q 对「当前手上那块」32K 个 K/V 算 attention,同时把这块 K/V 传给右邻居——通信被计算盖住。
  3. 为什么 Llama 3 敢直接 all-gather K/V:405B 用 GQA,每层每 token 的 K+V 只有 8 heads × 128 × 2 × 2 B = 4 KB,128K token 也只有 512 MB/层,gather 过来可接受。
  4. 负载均衡: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 逐元素,本地做

工程视角 / 真实世界

💬 能聊的要点

  1. 「Megatron TP 把第一个矩阵按列切、第二个按行切,中间的逐元素激活不用通信,每层前向只要 2 次 all-reduce;但它在关键路径上且很频繁,所以只放在 NVLink 域内,一般 TP≤8。」
  2. 「PP 的 bubble 约 (p-1)/(m+p-1),1F1B 不减少 bubble 但把在飞 micro-batch 限制在 p 以内,省 activation;interleaved 和 DualPipe 才进一步压 bubble。」
  3. 「组合原则是通信越密越往里:TP 在节点内,PP 可以跨节点,DP 最外层;MoE 的 EP 靠 all-to-all,DeepSeek-V3 干脆不用 TP,用 PP16 + EP64 + ZeRO-1,并限制 token 最多去 4 个节点。」

常见误区

自测(心算/口答即可)

  1. PP=16,每步 64 个 micro-batch,bubble 占比约多少?
    答案15/(64+15) ≈ 19%。
  2. 为什么 TP 通常不跨节点,而 PP 可以?
    答案TP 每层前向反向各有 2 次 all-reduce,数据量是整份 activation,且阻塞计算;PP 只在 stage 边界做点对点 send/recv,频率和数据量都小得多。
  3. Llama 3 405B 在 16384 卡上从 8K 序列切到 128K 序列,为什么 DP 从 128 降到 8?
    答案长序列 activation 放不下,需要 CP=16 切序列;总卡数不变,TP×PP 不变,只能从 DP 里挪出 16 倍:128/16=8。

延伸

下一课 → 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|>

要点:

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> —(没有下一个) 丢弃

一句话总结:label 就是「右移一位的输入」,把非 assistant 部分的 label 改成 -100,模型就只为「回答 + 结束符」负责。

4. 数据:少而精

代码片段

用 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)

工程视角 / 真实世界

💬 能聊的要点

  1. 「SFT 在算法上就是继续做 next-token prediction,区别在于数据是 chat template 格式化的对话,且 loss 只算 assistant 部分(其余 label 设为 -100);模型学会在 <|eot_id|> 处停止,也是因为这个 token 被算进了 loss。」
  2. 「Chat template 是训练和推理之间的协议:special token 必须是单 token、推理时模板和 stop token 要与训练一致,很多线上『模型变笨/不停止』问题最后查下来都是模板不匹配。」
  3. 「LIMA 用 1000 条样本就调出不错的助手,说明知识主要来自预训练,SFT 更多是教格式和风格;现代 post-training 大量依赖合成数据和 rejection sampling。」

常见误区

自测(心算/口答即可)

  1. 一个模型生成回答后一直不停止,最可能的两个原因?
    答案① 推理服务没有把 <|eot_id|>/<|im_end|> 配成 stop token;② SFT 时结束 token 被 mask 掉没算 loss,模型没学会生成它(或模板不匹配)。
  2. 为什么 <|im_end|> 必须是一个独立的 special token,而不能让 BPE 把它切成若干普通 token?
    答案一是生成时要能用单个 token id 判断停止;二是防注入——用户文本里的同名字符串会被切成普通 token,不会被当成真正的轮次边界。
  3. base model 和 instruct model 的参数结构有区别吗?
    答案没有,架构和参数量相同,只是权重经过了 SFT/RL 继续训练(可能额外训练了 special token 的 embedding)。

延伸

下一课 → 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 上:

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 就是反推每个回答的「等级分」,让观测到的胜负最可能发生。

再代数字:

  1. RM 给 chosen 打 2.0、rejected 打 0.5 → 分差 1.5 → σ(1.5) ≈ 0.82 → loss = −log 0.82 ≈ 0.20(判对了,loss 小)。
  2. 反过来 chosen 0.5、rejected 2.0 → σ(−1.5) ≈ 0.18 → loss ≈ 1.70(判反了,loss 大,梯度会把两者拉开)。
  3. 两者都 +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 ❌

🧩 难点拆解:PPO 的 4 个模型与 KL 惩罚

先直觉:把 actor 想成一个在练写作文的学生——

再代数字:

  1. KL 惩罚:某位置 actor 选了「绝对」,它给这个 token 的概率 0.6,reference 只给 0.1 → 该 token 的 log-ratio = ln(0.6/0.1) ≈ 1.79。β = 0.05 时惩罚 ≈ 0.09。
  2. 如果 200 个 token 都这样偏离,累计惩罚 ≈ 18,而 RM 满分也就 2–3 分——偏得太远「不划算」,模型只会在真正能涨分的地方偏离。
  3. 最终用于训练的 reward = RM 分数 − β × Σ_token log(π_θ/π_ref)。
  4. Advantage:critic 在某个 token 处预测「从这里往后能拿 1.2 分」,实际拿到 1.5 → advantage = +0.3 → 提高这个 token 的概率;实际只有 0.8 → −0.4 → 压低。没有 critic 的话,只能拿整篇总分去奖励每个 token,信号噪声很大。
  5. 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 步——

  1. 在「多拿 reward、但 KL 不能太大」这个目标下,数学上可以证明最优策略长这样:π*(y|x) ∝ π_ref(y|x) · exp(r(x,y)/β),即在 reference 的基础上,reward 越高的回答概率被放大得越多。
  2. 这个关系是一一对应的,可以反解:r(x,y) = β · log(π*(y|x)/π_ref(y|x)) + β·log Z(x)。Z(x) 是一个只和 prompt 有关的归一化常数,很难算。
  3. Bradley-Terry 只用同一个 prompt 下两个回答的分差,β·log Z(x) 两边一样,相减直接抵消。于是「训练 RM → 用 RL 找最优策略」这两步可以合并成:直接训练 policy,让它隐含的 reward 满足偏好。

类比:与其先训一个「裁判」再让选手照着裁判练,不如直接让选手在「谁赢谁输」的记录上学——选手自己的概率变化本身就定义了一个裁判。

再代数字(β = 0.1):

一句话总结:在 KL 约束下 reward 和最优策略可以互相换算,prompt 相关的常数在成对比较里抵消,所以 policy 本身就能充当 reward model。

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

工程视角 / 真实世界

💬 能聊的要点

  1. 「PPO 版 RLHF 要 4 个模型:actor 和 critic 要训练,reward model 和 reference 冻结;KL 惩罚用来防 reward hacking,critic 用来算 advantage 降方差。」
  2. 「DPO 利用 KL 约束下最优策略的闭式解,把 reward 表示成 β·log(π/π_ref),从而把 RLHF 变成偏好对上的二分类 loss——只要 policy 和 reference 两个模型,不需要在线采样。」
  3. 「DPO 的代价是 offline,无法探索;PPO 等 online 方法上限更高但工程重。Llama 3 选了迭代式 DPO,而推理模型又回到了 online RL(GRPO)。」

常见误区

自测(心算/口答即可)

  1. 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)。
  2. DPO 训练中 chosen 和 rejected 的 log-prob 都在下降,但 margin 在增大,这是否可能?
    答案可能,而且很常见:loss 只约束两者的相对差,rejected 降得更快即可。这也是 DPO 的一个已知副作用。
  3. 为什么说 RLHF 不能像 AlphaGo 那样一直跑下去?
    答案奖励来自一个神经网络 RM,它对分布外的回答打分不可靠,模型会找到对抗样本式的漏洞;围棋的胜负是可验证的真实奖励,不能被钻空子。

延伸

下一课 → 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          ...
  1. A_i = (r_i - mean(r)) / std(r):减均值 → 比同组平均好的为正、差的为负;除以 std → 不同题目的 advantage 量级统一(有的题分差大、有的小)。
  2. 部分得分的代码题,4 个回答单测通过率 [0.2, 0.4, 0.9, 0.5]:mean = 0.5,std ≈ 0.255 → A ≈ [-1.18, -0.39, +1.57, 0]。第 4 个恰好等于平均,不奖不罚。
  3. 一组全对 [1,1,1,1] 或全错 [0,0,0,0]:每个 r_i - mean = 0 → A 全为 0 → 零梯度。
  4. 得到 A_i 后,回答 i 的每一个 token 都用同一个 A_i:y1 里所有 token 概率一起被推高,哪怕其中某一步写得很啰嗦。这就是「信用分配(credit assignment:把最终成败归功/归咎到具体哪一步)粗糙」的含义。

其余沿用 PPO 的 clipped objective,KL 惩罚直接加在 loss 上(相对 reference)。

一句话总结:GRPO 用「同题多次作答的组内平均」代替 critic 当基线,组内标准化得到 advantage,简单、省显存,代价是要多采样、且整条回答共享一个信号。

3. DeepSeek-R1-Zero 与 R1 的故事

R1-Zero:直接在 DeepSeek-V3-Base 上做 GRPO,不经过 SFT,奖励只有「答案对不对 + 格式对不对」。

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 算力和推理时思考算力都平滑提升。推理时多花算力的方式:

对推理服务的直接后果:输出从几百 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())   # 把新权重同步给推理引擎

工程视角 / 真实世界:为什么 RL 是 infra 问题

💬 能聊的要点

  1. 「GRPO 去掉了 PPO 的 critic:同一个 prompt 采 G 个回答,用组内 reward 的均值和标准差做归一化作为 advantage;配合可验证的规则奖励(RLVR),既不需要 value model 也不需要 reward model。」
  2. 「R1-Zero 证明了纯 RL、不经 SFT 就能涌现长 CoT 和自我反思,AIME pass@1 从 15.6% 到 71%;R1 再用 cold-start SFT、拒绝采样和多阶段 RL 解决可读性问题。」
  3. 「RL 训练本质是『推理 + 训练』的混合负载:rollout 占大头、长度长尾、每步要在训练和推理两种并行布局间同步权重,所以 verl/OpenRLHF 这类框架的核心难点都在 infra。」

常见误区

自测(心算/口答即可)

  1. GRPO 中一组 8 个回答全部答对,这组对梯度的贡献是多少?说明什么?
    答案advantage 全为 0,贡献为 0(KL 项除外)。说明题太简单,训练数据需要按难度筛选或动态过滤。
  2. 相比 PPO,GRPO 在显存上省掉了什么?代价是什么?
    答案省掉了与 actor 同规模的 critic(参数、梯度、optimizer state)。代价是每个 prompt 要采样多个回答(rollout 量增加),且整条回答共享一个 advantage,信用分配更粗。
  3. 为什么 RLHF 不能长跑,而 RLVR 可以?
    答案RLHF 的奖励来自可被 hack 的神经网络 RM;RLVR 的奖励来自规则验证(答案比对、单元测试),很难被钻空子。

延伸

下一课 → 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

🧩 难点拆解: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:

  1. 输出 h = W + 0 = W,和原模型完全一样。
  2. ∂L/∂B = g · A · x = 0.5g ≠ 0 → B 第一步就动起来。
  3. ∂L/∂A = g · B · x = 0 → A 第一步不动;等 B 变成非零后,A 的梯度也就有了。
  4. 若 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

4. QLoRA:把冻结的 base 压到 4 bit

QLoRA(2023)三件套:

效果:65B 模型能在单张 48GB GPU 上微调。梯度只流向 BF16 的 LoRA 参数,4-bit base 始终冻结。代价是反量化带来额外计算,训练更慢。

5. 其他 PEFT 方法(一句话认识)

代码片段

一个最小的 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

工程视角 / 真实世界

💬 能聊的要点

  1. 「LoRA 冻结 W,只训 ΔW=B·A,B 零初始化保证起点等于原模型;显存上省掉的是 base 参数的梯度和 Adam 状态——7B 从 16 B/param 的约 112GB 降到 BF16 权重的 14GB 左右。」
  2. 「QLoRA 把冻结的 base 用 NF4 存成 4 bit、前向时反量化成 BF16 计算,梯度只流向 LoRA;65B 模型可以在单张 48GB 卡上微调。」
  3. 「LoRA 可以 merge 回原权重做到零推理开销,也可以不 merge 做 multi-LoRA serving:一个 base 服务上百个 adapter,base 做共享 GEMM,adapter 部分用 SGMV 类 kernel 分段计算,vLLM/SGLang 都已支持。」

常见误区

自测(心算/口答即可)

  1. 一个 8192×8192 的矩阵挂 r=8 的 LoRA,可训练参数多少?占原矩阵多少比例?
    答案2×8192×8 = 131,072,约 0.2%(原矩阵约 67M)。
  2. 为什么 B 要零初始化而 A 随机初始化?两个都零初始化会怎样?
    答案B=0 保证起始 ΔW=0、不破坏原模型;A 随机保证梯度能流动。两者都为 0 时,A 和 B 的梯度都为 0,永远学不动。
  3. 70B 模型用 QLoRA,base 权重大约占多少显存?
    答案4 bit ≈ 0.5 B/param,约 35 GB,再加少量量化常数和 LoRA 参数/状态,加上 activation,单张 80GB 卡可行。

延伸

下一课 → 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 格式敏感,小题集的分差往往在噪声之内。

为什么要关心

核心直觉

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%。看起来简单,打分方式至少有两种:

再加上 0-shot / 5-shot、有没有 CoT、prompt 模板的细节,同一个模型在「同一个 benchmark」上相差几个点很常见。所以只比同一套 harness、同一设置下的数字。

Benchmark 还会饱和:前沿模型在 MMLU 上已超过 90%,区分度消失,于是出现更难的替代品:MMLU-Pro(10 个选项)、GPQA Diamond(198 道研究生水平科学题,题目设计成「Google 也搜不到」)、Humanity's Last Exam 等。

3. 可验证的生成题:数学、代码、Agent

代码题常报 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

「写一封得体的拒绝邮件」没有标准答案,只能比较:

5. 污染(Contamination)与 Goodhart 定律

Benchmark 题目会出现在网上,进而被爬进预训练数据(第 27 课)。模型「背过」原题,分数就失去意义。常见对策:

更深一层是 Goodhart 定律:一个指标一旦成为优化目标,它就不再是好指标。团队盯着某个榜单调数据配比,分数会上涨,真实能力未必。所以严肃团队都维护内部 held-out 评测集,并且最终看线上指标。

🧩 难点拆解:这 1 分差距是真的吗?

直觉:benchmark 分数是对「模型真实正确率 p」的一次抽样估计,题目越少,估计越抖。就像用 30 个用户的 A/B 测试去下结论。

代入数字:正确率的标准误 SE = √(p(1−p)/n),95% 置信区间约 ±2 SE。

Benchmark题数 n假设 pSE±2 SE
AIME(一年)3050%9.1 分±18 分
GPQA Diamond19850%3.6 分±7 分
SWE-bench Verified50060%2.2 分±4.4 分
MMLU约 14,00080%0.34 分±0.7 分
  1. AIME 上 73.3 vs 70.0 只差 1 道题(30 题里 22 道 vs 21 道),远在噪声内;
  2. 每题采 16 次取平均(avg@16)能压掉「采样随机性」那部分噪声,但压不掉「只有 30 道题」这部分;
  3. 所以小题集上要看多次采样均值,最好再跨多个 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

工程视角 / 真实世界

💬 能聊的要点

  1. 「Perplexity 是 exp(cross-entropy),便宜又连续,适合训练中监控;但它按 token 计,换了 tokenizer 不可比,要换成 bits-per-byte。」
  2. 「同一个 benchmark,log-likelihood 打分和生成打分、几-shot、prompt 模板不同,结果能差好几个点,所以只比同一 harness 下的数字。AIME 只有 30 题,标准误约 9 分,1–2 题的差距没有统计意义。」
  3. 「LLM-as-a-Judge 便宜可重复,但有位置、长度、自我偏好三种偏差;benchmark 有污染和 Goodhart 问题,严肃团队都靠内部 held-out 集和线上指标兜底。」

常见误区

自测(心算/口答即可)

  1. 模型 A 用 32K 词表、B 用 128K 词表,A 的 per-token loss 更低,能说明 A 更好吗?
    答案不能。词表越大,每个 token 平均覆盖的文本越多,per-token loss 天然更高。要换算成 bits-per-byte(总 NLL ÷ ln2 ÷ 原文字节数)再比。
  2. GPQA Diamond(198 题),模型 A 62%、模型 B 58%,差距显著吗?
    答案SE ≈ √(0.6×0.4/198) ≈ 3.5 分,两者之差的标准误约 3.5 × √2 ≈ 4.9 分,4 分差距在 1 个标准误以内,不能下结论。
  3. 用 GPT 类模型当评委比较两个回答,至少要做哪两件事来减少偏差?
    答案① 交换两个回答的顺序各评一次(消除位置偏差);② 控制或校正长度(如 length-controlled win rate),必要时避免让同家族模型当评委。

延伸

下一课 → 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。

为什么要关心

核心直觉

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 数随像素面积线性增长:

4. Early fusion vs Adapter

🧩 难点拆解:一张截图到底多贵

直觉:把一张图想成一段「看不见的长 prompt」:它占 prefill 算力、占 KV Cache,只是这段 prompt 由 ViT 写成。

代入数字:Qwen2.5-VL-7B(LLM 部分 28 层、4 个 KV head、head_dim 128,BF16),输入一张约 1008×1008 的截图:

  1. token 数:每 token 28×28 像素 → (1008/28)² = 36² = 1296 个图像 token。合并之前 ViT 要处理 4 倍、即 5184 个 patch,所以 Qwen2.5-VL 在 ViT 里大量用 window attention 控制平方开销。
  2. KV Cache:每 token = 2 × 28 × 4 × 128 × 2 B = 56 KiB(第 19 课公式),1296 个 token ≈ 71 MiB。多轮对话里贴 10 张截图,就是 1.3 万 token、约 0.7 GiB,只是一个请求的量。
  3. Prefill 算力:≈ 2 × 7.6e9 参数 × 1296 token ≈ 2e13 FLOPs,H100 按 50% MFU 约 40 ms,再加上 ViT 自己的 forward(Qwen2-VL 的 ViT 约 675M 参数,但要处理 5184 个 patch)。
  4. 所以同一个请求,「带一张高清图」的 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 完全一样

工程视角 / 真实世界

💬 能聊的要点

  1. 「主流 VLM 是 ViT + Projector + LLM:图片切 patch 过 ViT,MLP 映射到 LLM 的 embedding 空间,当作图像 token 拼进 prompt。LLaVA 两阶段训练:先只训 Projector 对齐,再解冻 LLM 做视觉指令微调。」
  2. 「图像 token 数和像素面积成正比,Qwen2-VL 每个 token 对应 28×28 像素,一张 1000 像素见方的截图约 1300 个 token,TTFT 和 KV Cache 都按这个量算。」
  3. 「VLM 推理比纯文本多三件事:CPU 预处理、ViT encoder 的计算和缓存、prefix caching 要对图像内容做 hash。趋势是把 encoder 也拆成独立服务。」

常见误区

自测(心算/口答即可)

  1. LLaVA-1.5 用 CLIP ViT-L/14、输入 336×336,一张图产生多少个图像 token?
    答案336 / 14 = 24,24 × 24 = 576 个。
  2. Qwen2-VL 输入一张 560×840 的图,大约多少图像 token?
    答案每 token 28×28 像素:(560/28) × (840/28) = 20 × 30 = 600 个。
  3. 为什么 VLM 的 prefix caching 不能只比较 token id?
    答案不同图片在 prompt 里都表示成同一个占位 token id,只比 id 会把内容不同的图当成相同前缀而错误复用 KV;必须把图像内容的 hash 纳入缓存 key。

延伸

下一课 → 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(就是平时说的「显存」)

类比后端: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):

大白话:在 H100 上,每从 HBM 读 1 byte,你得在上面做将近 300 次运算,Tensor Core 才不会闲着。kernel 强度低于 ridge point → memory-bound(瓶颈是搬数据);高于 → compute-bound(瓶颈是算)。

🧩 难点拆解:算术强度与 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 带宽

工程视角 / 真实世界

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. A100 上一个 kernel 的算术强度是 50 FLOP/byte,它是 compute-bound 还是 memory-bound?
    答案memory-bound。A100 平衡点约 156 FLOP/byte,50 低于它,性能由带宽决定。
  2. 为什么 batch=1 的 GEMV 算术强度约为 1?
    答案每个 BF16 权重占 2 bytes,读进来只和输入向量的一个元素做一次乘加(2 FLOP),2 FLOP / 2 bytes = 1。
  3. Triton 和 CUDA 编程视角的最大区别是什么?
    答案CUDA 以单个 thread 为视角,自己管 shared memory 和同步;Triton 以一个 block(一块数据)为视角,线程映射和 shared memory 由编译器处理。

延伸

下一课 → 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)

3. 算一个例子:Llama-3-8B,H100,batch=1

实际框架由于 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 并不能无限提升吞吐,有两个限制:

  1. KV Cache 不共享:每个请求的 attention 要读自己的 KV Cache,这部分的强度不会随 batch 增大而提高。Llama-3-8B 每 token KV 128 KB,64 个请求 × 2048 上下文 = 16 GB,每步要读的 KV 已经和权重一样多了,单步时间翻倍。
  2. 显存容量: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

一步步看:

  1. batch=1:读 16 GB 要 4.8 ms,算 16 GFLOP 只要 0.016 ms——Tensor Core 99% 以上的时间在等数据,这就是「memory-bound」。
  2. batch=64(忽略 KV):读的字节数不变,FLOP 变成 64 倍,但计算仍然只要 1 ms,远小于 4.8 ms;单步耗时不变,吞吐 ×64。
  3. 算上 KV:每个请求各读自己的 KV,64 × 2048 × 128 KB = 16 GiB,读取量翻倍,单步耗时也翻倍。吞吐还是比 batch=1 高约 31 倍,但不再是 64 倍。
  4. 要让计算耗时追上读取耗时,强度需要接近 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")

工程视角 / 真实世界

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. 一个 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 通信开销)。
  2. 为什么 prefill 的算术强度约等于 prompt 长度?
    答案每个权重读进来一次,要和 prompt 中每个 token 各做一次乘加,所以 FLOP/byte 约等于 token 数。
  3. Llama-3-8B 在 H100 上 decode,batch 从 1 提到 32,吞吐大约变成多少倍?
    答案短上下文时接近 32 倍(权重读取共享,单步时间主要是读 16 GB 权重);上下文越长,KV 读取越多,倍数越低(如 2048 上下文时 32×2048×128 KB = 8 GiB,单步约 7.4 ms,约 21 倍)。

延伸

下一课 → 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-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 个并发

四个特点叠在一起让它很难管理:

  1. 大:如上,很容易超过权重本身。vLLM 论文中,13B 模型在 A100 40GB 上,权重占约 65%,KV Cache 占 30% 以上。
  2. 动态增长:每 decode 一步每个请求就多一个 token 的 KV。
  3. 长度未知:请求什么时候输出 EOS,事先不知道。
  4. 直接决定吞吐:上一课说过,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

工程视角 / 真实世界

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. 一个 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。
  2. Llama-3-8B 的 KV Cache 从 BF16 改成 FP8,同样显存能多放多少 token?
    答案每 token 从 128 KB 降到 64 KB,可放 token 数翻倍。
  3. 为什么说 KV Cache「省计算、费带宽」?
    答案它避免重算历史 token 的 K/V(省计算),但每个 decode step 都要从 HBM 读出全部历史 KV 做 attention,上下文越长读得越多(费带宽和显存)。

延伸

下一课 → 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

效果:

🧩 难点拆解:跟着一个请求走一遍 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)                # 引用归零才真正回收

工程视角 / 真实世界

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. block size=16,一个请求当前有 100 个 token,占几个 block?浪费几个 slot?
    答案ceil(100/16) = 7 个 block,共 112 个 slot,浪费 12 个。
  2. 为什么 PagedAttention 没有 external fragmentation?
    答案所有物理 block 大小相同,且请求不要求连续的物理空间,任意空闲 block 都能被任意请求使用。
  3. beam search 为什么比 parallel sampling 从共享中省得更多?
    答案parallel sampling 只共享 prompt;beam search 的各个候选还共享大量已生成的前缀,并且随着 beam 的分叉和淘汰动态共享,所以共享比例更高(论文中 37.6–55.2% vs 6.1–9.8%)。

延伸

下一课 → 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。

逐步看调度器每一步做什么:

  1. 先给 running 里的 60 个 decode 请求各分 1 个 token(共 60)。
  2. budget 还剩 2048 − 60 = 1988 → 分给 P,P 的 num_computed_tokens 从 0 → 1988。
  3. 下一步重复:P 从 1988 → 3976 → … → 9940 → 10000,最后一块算完才产出 P 的第 1 个 token。
  4. 期间任何请求输出 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),刚好用满。

  1. 这一步 A 的最后一个 block 写满,需要第 41 个 block → 空闲池为 0 → allocate_slots 失败。
  2. 调度器抢占 running[-1],也就是最后到的 C:释放它的 25 个 block,C 放回 waiting 队首。
  3. A 拿到 1 个新 block 继续 decode;B 照常。空闲池剩 24 个。
  4. 之后 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 小时拷贝很碎。
  5. 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

工程视角 / 真实世界

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. static batching 下,一批 8 个请求,其中 7 个输出 50 token,1 个输出 1000 token,slot 利用率大约多少?
    答案总共有效 token step = 7×50 + 1000 = 1350,占用 slot×step = 8×1000 = 8000,利用率约 17%。
  2. 为什么线性层可以跨请求拼 batch,而 attention 需要区分请求?
    答案线性层对每个 token 独立计算,与 token 属于哪个请求无关;attention 要让 token 只关注同一请求中的 token,而且每个请求的 KV 长度不同。
  3. token budget 从 2048 调到 512,TTFT 和 TPOT 分别怎么变?
    答案长 prompt 要被切成更多 chunk,TTFT 变长;每步工作量更小,decode 的 TPOT 更低、更平稳。

延伸

下一课 → 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:

处理 Block 2(出现了更大的分数 4):

最后: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

工程视角 / 真实世界

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. N=16K,32 个 head,BF16,标准实现中一层的 S 矩阵占多少显存?
    答案16384² × 2 bytes = 512 MB 每个 head,32 个 head 共 16 GB(batch=1)。FlashAttention 不需要这部分。
  2. online softmax 中,为什么遇到更大的最大值时,要把 acc 乘以 exp(m_old − m_new)?
    答案之前的项都是按 exp(s − m_old) 累积的,要改用新的基准 m_new,每一项都需要乘以 exp(m_old − m_new),这样所有块的结果才在同一基准上,可以直接相加。
  3. 为什么 backward 选择重算 P,而不是保存 P?
    答案保存 P 需要 O(N²) 显存,并且要从 HBM 读回;从 SRAM 中的 Q、K tile 重算虽然多了 FLOPs,但 attention 在这里是 IO-bound,重算反而更快,同时省下显存。

延伸

下一课 → 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。

为什么要关心

核心直觉

1. 第一步:别把计时测错

CUDA 是异步的:Python 调 A @ B 只是把 kernel 放进队列就返回,GPU 可能还没开始算。常见三个坑:

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。

  1. 朴素写法 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。
  2. Fused kernel:每行读一次 x、在寄存器里算平方和、rsqrt、乘 w,写一次结果 = 128 MiB → 约 40 µs,一次 launch。
  3. 实际差距常常更大:朴素写法为了数值稳定会先把 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. 什么时候必须手写

代码片段

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)

工程视角 / 真实世界

💬 能聊的要点

  1. 「Transformer 里矩阵乘通常已经接近峰值,时间常漏在中间的 norm、激活、残差这些 memory-bound 小算子上;优化方法是 fusion,把多次 HBM 往返压成一次读、一次写。」
  2. 「Triton 以一个 block 的数据为单位编程:program_id → load → 块内计算 → store,线程映射和 shared memory 交给编译器;torch.compile 的 Inductor 后端就是自动生成 Triton kernel 做融合,reduce-overhead 模式再加 CUDA Graph。」
  3. 「编译器能做算子拼接式的融合,但 FlashAttention 这种改了计算顺序的算法、和矩阵乘深度融合的 fused MoE / 量化 GEMM,还得手写。」

常见误区

自测(心算/口答即可)

  1. 一个 [16384, 8192] 的 BF16 张量做「GELU + 加 bias」,朴素实现(两个独立 kernel)和融合实现在 H100 上各约多久?
    答案张量 256 MiB。朴素:GELU 读写各一次、加 bias 读写各一次,约 1 GiB 流量 → 约 0.32 ms;融合:读一次写一次约 512 MiB → 约 0.16 ms,快一倍,还少一次 launch。
  2. Triton kernel 的 launch grid (n_rows,) 是什么意思?
    答案启动 n_rows 个 program 实例,每个通过 tl.program_id(0) 拿到自己的编号,处理对应的一行数据。
  3. 为什么 fused linear + cross-entropy 能省大量显存?
    答案大词表下完整 logits(序列长度 × vocab)非常大,例如 8K × 128K 的 BF16 约 2 GB;融合后按块算 logits、立刻算 loss 和梯度再丢掉,不需要同时存下完整 logits。

延伸

下一课 → 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

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: 换个主题"   ← 多轮对话自然形成分叉

🧩 难点拆解:哈希链 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 哈希链:

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 后面再拆一次

两者共同点:如果来一个 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 数

对照看 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

工程视角 / 真实世界

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. block size=16,两个请求共享前 1000 个 token,vLLM APC 最多能复用多少 token?
    答案floor(1000/16) × 16 = 992 个 token(62 个完整 block),剩余 8 个 token 所在的 block 未写满/不完全相同,不能复用。
  2. 为什么 vLLM 的 block 哈希要包含父 block 的哈希?
    答案因为同样的 16 个 token 出现在不同前缀之后时,KV 是不同的;包含父哈希后,哈希相等就等价于从开头到当前 block 的整段前缀相等。
  3. 为什么 RadixAttention 淘汰叶子节点,而不是任意节点?
    答案靠近根的节点是更多请求共享的公共前缀,价值更高;而且子节点的 KV 依赖父节点,淘汰中间节点会使下面的子树无法使用。叶子优先保证公共前缀留到最后。

延伸

下一课 → 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):

  1. 读权重:16 GB ÷ 3.35 TB/s ≈ 4.8 ms——不管算几个位置都要付;
  2. 算 1 个位置:2 × 8B FLOPs ÷ 989 TFLOPS ≈ 0.016 ms;
  3. 算 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:无论如何本轮至少产出 1 个 token(被拒时 target 重采的那个,或全接受时白送的 bonus token);
  2. 第 2 项 α:第 1 个 draft 被接受的概率,接受就多 1 个;
  3. 第 3 项 α²:前 2 个 draft 都被接受的概率——一旦某个位置被拒,后面全作废,所以必须「连续命中」;
  4. 依此类推,直到 α^γ(全部命中)。

代入 α=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:

可以证明最终每个位置的 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
  1. 被接受的总概率 = 0.2+0.3+0.2 = 0.7,所以被拒的概率 = 0.3(全来自 B 被砍掉的那一半);
  2. 残差 max(0, p−q) = (0.3, 0, 0),归一化后 = (1, 0, 0),即被拒时一定重采到 A;
  3. 最终: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 的演进:

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. α=0.5、γ=3 时,每轮期望产出多少 token?
    答案(1−0.5⁴)/(1−0.5) = 0.9375/0.5 ≈ 1.875 个。接受率低时收益很有限。
  2. draft 在某 token 上给了 q=0.6,target 给 p=0.3,接受概率是多少?被拒后从哪里采样?
    答案min(1, 0.3/0.6)=0.5;被拒后从 max(0, p−q) 归一化后的残差分布采样,该 token 自身在残差里概率为 0。
  3. 为什么做 RAG 问答时 n-gram / prompt lookup 往往有效,而写诗时几乎无效?
    答案RAG 回答常大段引用检索文档里的原文,prompt 中能找到匹配的续写;写诗的输出几乎不复现输入,找不到可抄的片段,α≈0。

延伸

下一课 → 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。

  1. 对称(范围 [−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)以内。
  2. 非对称(范围 [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 个格子都被用上,格子更细。
  3. 加一个 outlier:同组再放一个 10.0,对称 s = 10/7 ≈ 1.43,−0.1 和 0.3 都变成 q=0,信息全丢。

一句话:scale 由组内最大值决定,一个 outlier 会把整组的格子撑粗——所以要缩小「共享一个 scale 的范围」,或者事先把 outlier 处理掉(下面的 AWQ/SmoothQuant)。

粒度(多少个元素共享一个 s)决定精度:

两大流派:谁在瓶颈就量化谁

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:训练后量化,只需少量校准数据)

🧩 难点拆解: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]。

  1. s_1 = √(100/0.5) ≈ 14.1,s_2 = √(1/0.5) ≈ 1.41;
  2. 平滑后激活最大值 [100/14.1, 1/1.41] ≈ [7.07, 0.71],权重最大值 [0.5×14.1, 0.5×1.41] ≈ [7.07, 0.71];
  3. 激活两 channel 的差距从 100 倍降到 10 倍,权重从 1 倍升到 10 倍——两边都「中等难」,per-tensor/per-token INT8 都能接受。α=0.5 时每个 channel 两边的最大值恰好相等,都是 √(max|X_j| · max|W_j|)。

一句话:激活难量化、权重好量化,就用一个数学等价的缩放把难度从激活匀给权重。

FP8:浮点也能 8 bit

🧩 难点拆解: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 原生支持)

🧩 难点拆解: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]。

  1. MXFP4 的 scale 只能是 2 的幂,取 0.25:值/0.25 = [0.4, 1.2, −0.8, 6] → 就近取 FP4 → [0.5, 1, −1, 6];
  2. 还原 ×0.25 = [0.125, 0.25, −0.25, 1.5],误差可接受;
  3. 若整个 tensor 共用一个 scale,而别处有个 outlier 96,scale 得是 16:0.1/16 等全部变成 0,这一块彻底丢失。
  4. 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 更平滑

工程视角 / 真实世界

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. 一个 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 常保留高精度,实际略大)。
  2. 为什么 SmoothQuant 要「迁移」难度,而不是直接用 per-token 量化激活?
    答案激活的 outlier 集中在固定的少数 channel(列),而 per-token 是按行分组,无法隔离列方向的 outlier;per-channel 量化激活又和 GEMM 的计算方式不兼容。缩放 channel 把它挪到权重(权重可以 per-channel 量化)。
  3. Llama-3-70B 开 FP8 KV Cache,每 token KV 多少?
    答案80 × 8 × 128 × 2 × 1B ≈ 160KB,是 BF16 的一半。

延伸

下一课 → 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]]。

  1. GPU0 持有 A 的第 0 列,算 h0 = 1·1 + 1·3 = 4;GPU1 持有第 1 列,算 h1 = 1·2 + 1·4 = 6(无通信);
  2. GPU0 持有 B 的第 0 行,算 4 × [1,1] = [4, 4];GPU1 持有第 1 行,算 6 × [2,0] = [12, 0];
  3. 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):

  1. dispatch:把 t 的 hidden 向量复制两份,一份发 GPU1、一份发 GPU3(其他卡上的 token 同时也在互相发——这就是 all-to-all);
  2. expert 计算:GPU1 用 E3 算出 o3,GPU3 用 E6 算出 o6,各自把发来的 token 拼成一个大矩阵做 grouped GEMM;
  3. 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 卡):

  1. TP=8:8 张卡存的是同一批 token 的同一份 KV,系统总容量 = 单卡容量 = 40 GB ÷ 69 KB ≈ 60.8 万 token;
  2. DP=8:每卡存不同请求,总容量 = 8 × 60.8 万 ≈ 486 万 token,可同时跑的请求数约多 8 倍;
  3. 代价: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:

  1. KV 大小 = 4000 × 320 KiB ≈ 1.22 GiB;
  2. 走 400 Gb/s RDMA(约 50 GB/s)传输 ≈ 26 ms;
  3. 同一 prompt 在 8×H100 上 prefill:2 × 70B × 4000 ≈ 560 TFLOPs,按 50% 利用率约 140 ms;
  4. 传输约占 prefill 的 1/5,而且可以按层流水(第 1 层 KV 算完就开始发,同时算第 2 层),大部分被掩盖。

一句话:PD 分离用「多搬一次 KV」换来 prefill 和 decode 互不干扰、各自最优配置;只要 KV 传输能被 prefill 计算掩盖,就是划算的。

代价:多一次 KV 传输(带宽要够)、两个池的配比要随流量调、系统复杂度大增。短 prompt、低并发时不值得。

大规模部署实例:DeepSeek 官方推理系统

DeepSeek 在 2025 年 2 月开源周公布的 V3/R1 线上架构:

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 本地算,可与通信重叠

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 开始流式返回

工程视角 / 真实世界

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. Llama-3-70B(80 层)TP=8 decode 一步要做多少次 all-reduce?
    答案每层 2 次,共 160 次(不计 embedding/LM head 的少量通信)。这就是 TP 必须走低延迟 NVLink 的原因。
  2. 为什么 DeepSeek 的 decode 单元(18 节点)比 prefill 单元(4 节点)大得多?
    答案decode 是 memory-bound,需要极大的并发 batch 才能把 expert GEMM 做「胖」;把 expert 摊到 144 张卡上后每卡只放约 2 个 routed expert,更多显存留给 KV Cache 和更大的 batch。prefill 本身 token 多、已经 compute-bound,小单元就够。
  3. PD 分离后,TTFT 和 TPOT 分别主要由哪个池决定?
    答案TTFT 由 prefill 池(排队 + prefill 计算)加 KV 传输决定;TPOT 由 decode 池的 batch 大小和负载决定,且不再被新请求的 prefill 打断。

延伸

下一课 → 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。

  1. 同一进程串行:一步 = 6 + 2 = 8 ms,GPU 利用率只有 6/8 = 75%;
  2. 拆进程并行:API Server 在做 detokenize 时,EngineCore 已在调度下一步、Worker 在跑 GPU——一步 ≈ 6 ms,吞吐约 1.33x;
  3. 模型越小、GPU 越快,CPU 那 2 ms 占比越高,拆进程的收益越大。

一句话:把「CPU 杂活」从「GPU 关键路径」上挪开,让 GPU 的每一步不再等 Python。

统一的 Scheduler:没有「prefill 阶段」

源码注释原话大意:调度器里没有 decoding phase 也没有 prefill phase,每个请求只有 num_computed_tokens 和 num_tokens_with_spec,每步尽量给请求分配 token 让前者追上后者。于是:

每步的总 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 开销压到最低

代码片段

先从用户视角看 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="")

再看引擎内部:

# 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
    ...

工程视角 / 真实世界

一个请求的一生(POST /v1/chat/completions,TP=1):

  1. FastAPI 收到请求 → AsyncLLM.generate():套 chat template、tokenize 成 1500 个 token,生成 EngineCoreRequest,经 ZMQ 发给 EngineCore;
  2. EngineCore busy loop(不停轮询输入队列、有活就调度执行的主循环)收到后 scheduler.add_request() 放进 waiting 队列;
  3. 下一步 schedule():prefix cache 命中前 1024 个 token(system prompt),剩 476 个在 budget 内一次算完;allocate_slots 分配 30 个新 block;
  4. Worker 的 GPUModelRunner 更新 persistent batch、构造 attention metadata(slot mapping = 本步新 token 的 KV 写到显存哪个槽位;block table = 每个请求的逻辑块 → 物理块映射,第 38 课),跑 forward + sample,得到第 1 个 token —— TTFT 结束;
  5. 之后每一步该请求只占 1 个 token 的预算,和其他请求一起 decode(CUDA Graph replay);
  6. 每个新 token 经 ZMQ 回到 API Server,detokenize 后流式推送;遇到 EOS/stop 串/max_tokens,Scheduler 释放其 block(放回空闲队列、哈希保留供以后复用)。

关键源码路径(vllm-project/vllm main 分支):

进阶:--async-scheduling 让调度下一步与执行当前步重叠(思路与 SGLang 的 overlap scheduler 相同);DP 部署时每个 DP rank 一个 EngineCore,由 coordinator.py 协调。

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. max_num_batched_tokens=2048,当前有 100 个 decode 请求,新来一个 5000 token 的 prompt,本步它能算多少 token?
    答案先给 running 的 100 个 decode 请求各 1 个 token,剩 1948,新请求本步 prefill 1948 个 token,剩余部分下一步继续(chunked prefill)。
  2. 为什么把 EngineCore 放到单独进程能提高吞吐?
    答案Python 有 GIL,tokenize/detokenize/HTTP 处理会抢占调度循环的 CPU 时间,导致 GPU 空等;拆进程后这些工作与调度和 GPU 执行真正并行。
  3. prefix cache 的块在请求结束后立即被清除吗?
    答案不会。引用计数归零后进入空闲队列但保留哈希,只有在需要新块时按 LRU 顺序被真正覆盖;在此之前命中可直接复用。

延伸

下一课 → 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)\. .*"))   # 约束解码

今天大多数人直接用 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。

  1. 串行:每步 13 ms,GPU 利用率 10/13 ≈ 77%;
  2. overlap:CPU 的 3 ms 藏在 GPU 的 10 ms 里,每步 ≈ 10 ms,吞吐约 1.3x;
  3. 前提是 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。

  1. 状态 0(开头):合法的是 Yes、No、Y(Y 后面还能接 es);模型 logits 为 Maybe=3.0, Yes=2.0, No=1.0, …,Maybe 分最高,但被 mask 成 −∞,只能在 Yes/No/Y 里采样 → 采到 Yes;
  2. 状态 1(已输出 Yes):只有 . 合法——只剩唯一选择,不必跑模型,直接追加,这就是 jump-forward;
  3. 状态 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 重叠。

代码片段

# 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

工程视角 / 真实世界

关键源码路径(sgl-project/sglang,python/sglang/ 下):

特色能力: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 等框架集成)。

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. overlap 调度时,batch N+1 需要 batch N 刚采样的 token 作为输入,但 CPU 还没拿到结果,怎么办?
    答案先用 future token 占位(只记下「去 GPU 上 future buffer 第几格取」的索引),GPU 执行 N+1 时直接从 N 的输出 buffer 读取真值;CPU 侧稍后再处理 N 的结果。
  2. s.fork(3) 后三个分支各生成 128 token,前缀 prefill 了几次?
    答案一次。三个分支的前缀在 radix 树上是同一条路径,后两个分支直接命中。
  3. compressed FSM 的 jump-forward 在什么情况下能省掉 forward?
    答案当 FSM 当前状态只有唯一合法的后续字符串(如 JSON 固定的 key 和标点)时,可以直接把它们追加进序列,无需模型逐 token 生成。

延伸

下一课 → 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
  1. 到达率从 12 加到 16,throughput 还在涨,goodput 却从 10.8 掉到 8;
  2. 所以只看 throughput 会把系统推到「看着忙、用户体验很差」的区间;
  3. 容量规划时应取 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):

  1. 假设给 KV 留 60 GiB → 能放 60 GiB ÷ 128 KiB = 491,520 个 token;
  2. 每个请求平均上下文 2000 输入 + 200 输出 = 2200 token(按满长度预留,保守估计)→ 最多同时约 491,520 ÷ 2200 ≈ 223 个请求(L 的上限);
  3. 若平均 E2E 延迟 W = 8 s,则最大吞吐 λ = L / W ≈ 223 / 8 ≈ 28 req/s;
  4. 想再提吞吐:要么降 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

工程视角 / 真实世界

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

其他常见选择:

诚实的选型建议:

  1. 先用 vLLM 或 SGLang 跑通,二者对主流模型都有 day-0 支持,功能差距在逐版缩小;
  2. 用自己的真实流量分布(长度、前缀、并发)在目标 SLO 下测 goodput,而不是看别人的博客数字;
  3. 前缀共享多、DeepSeek 类 MoE 大规模部署 → 重点测 SGLang;硬件或模型种类杂、需要最广生态 → vLLM;全栈 NVIDIA、有工程资源深调、追求 Blackwell 上极限性能 → 评估 TensorRT-LLM;
  4. 本地/边缘 → llama.cpp / Ollama。

💬 能聊的要点

常见误区

自测(心算/口答即可)

  1. 一个请求 TTFT=400ms,共输出 201 个 token,E2E=10.4s,TPOT 是多少?
    答案(10.4 − 0.4) / (201 − 1) = 50ms,约 20 tokens/s。
  2. 服务平均 E2E 延迟 8 秒,要支撑 5 req/s,系统里平均同时有多少个在跑的请求?
    答案Little's Law:5 × 8 = 40 个并发请求。KV Cache 要按 40 个请求的上下文来预留。
  3. 框架 A 最大吞吐比 B 高 20%,但在你的 SLO(P99 TPOT<40ms)下 goodput 比 B 低,应该选谁?
    答案选 B。最大吞吐通常出现在延迟已经超标的高并发区间,对你的业务没有意义;goodput 才是满足 SLO 下的真实容量。

延伸

下一课 → 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 个问题(面试 / 讨论高频)

按「能讲出来 = 真懂」的标准自查,括号里是对应课程:

  1. 一个 decoder-only Transformer 的一层里有什么?参数主要在哪?(14–16)
  2. 为什么 decode 是 memory-bound?batch size 为什么能提升吞吐却几乎不增加单步延迟?(36)
  3. KV Cache 怎么算?Llama-3-8B 跑 8K context、batch 32 要多少显存?(37)
  4. MHA / MQA / GQA / MLA 的区别和对 KV Cache 的影响?(19–20)
  5. MoE 为什么「总参数大、激活参数小」?对推理系统带来什么新问题(EP、all-to-all、负载不均)?(21–22, 44)
  6. PagedAttention 解决了什么问题?和 OS 虚拟内存怎么类比?(38)
  7. Continuous batching 和 static batching 的区别?chunked prefill 解决什么?(39)
  8. FlashAttention 为什么快?它减少了 FLOPs 吗?(40:没有,减少的是 HBM 读写)
  9. Prefix caching 在 vLLM 和 SGLang 中实现有什么不同?什么业务场景收益最大?(41)
  10. Speculative decoding 为什么能加速且不改变输出分布?什么时候反而变慢?(42)
  11. AWQ/GPTQ 和 FP8 的区别?weight-only 量化为什么能加速 decode?(43)
  12. TP 和 PP 分别适合什么场景?TP 为什么通常不跨机?(30, 44)
  13. 什么是 PD 分离?为什么要分?代价是什么(KV 传输)?(44)
  14. TTFT、TPOT、throughput、goodput 各是什么?怎么权衡?(47)
  15. vLLM 中一个请求从 HTTP 进来到 token 流出去经过了哪些组件?(45)
  16. 训练时显存都去哪了?为什么说 Adam 混合精度每个参数 16 bytes?ZeRO-1/2/3 分别切了什么?(28–29)
  17. SFT、RLHF、DPO、GRPO 的区别?为什么 RL 训练里 rollout(推理)是瓶颈?(31–33)
  18. RoPE 是什么?长上下文扩展(YaRN 等)在改什么?(13, 23)
  19. LoRA 为什么省显存?multi-LoRA serving 怎么做?(34)
  20. Scaling law 说了什么?Chinchilla 最优是什么意思,为什么现在的模型都「过训练」?(18)

没时间 hands-on 时,性价比最高的「半 hands-on」

你不需要写训练代码,但以下几件事每件只要 30–60 分钟,对「真懂」的帮助极大:

  1. 读 config.json:去 Hugging Face 打开 Llama-3-8B、Qwen3、DeepSeek-V3 的 config.json,对照第 26 课逐字段解释。
  2. 心算显存:对任意模型算「权重显存 + KV Cache 显存」,判断几张卡能跑、最多能并发多少请求(第 37 课公式)。
  3. 跑一次 vLLM / SGLang(有 GPU 时):vllm serve <model> 起服务,用 vllm bench serve 压测,观察 TTFT/TPOT 随并发的变化曲线。这比读十篇文章都直观。
  4. 跟一个 PR:在 vLLM / SGLang 仓库里找一个 「Add support for XXX model」 或 scheduler 相关的 PR,读 diff 和讨论。你会看到第 45/46 课的组件是怎么被改的。
  5. 看 Karpathy 的 nanochat / nanoGPT 仓库:不跑,只读 model.py,对照第 16 课。

持续跟进的信息源

一个可执行的 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 主题本课程对应备注
1Overview, tokenization00、06
2PyTorch (einops), resource accounting09、16(参数量)、18(6ND)、28(16 B/param)、35(算术强度)资源账本分散在多课,建议连读
3Architectures, hyperparameters13–16、25、26、🆕 18a18a 补了形状超参的经验区间
4Attention alternatives, MoE19–24
5GPUs, TPUs35、36TPU 本课程未展开
6Kernels, Triton40、🆕 40a
7–8Parallelism29、30、44
9, 11Scaling laws18、🆕 18a
10Inference36–47本课程这部分比 CS336 更深
12Evaluation🆕 34a
13–14Data(sources;filtering, dedup, mixing, synthetic)27CS336 用两讲,动手可做 A4
15Mid / post-training(SFT / RLHF)31、32
16Post-training, RLVR33
17Alignment, multimodality32、🆕 34b
18–19Guest 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 Systemsbenchmark / 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 的公式)

三条路线,按你的时间选

动手时的两个提醒

延伸

回到首页 → 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 的一层里有什么?参数主要在哪?

APre-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 能提吞吐却几乎不增加单步延迟?

Adecode 每步每请求只处理 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 的影响?

AMHA 每个 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 为什么「总参数大、激活参数小」?给推理系统带来什么新问题?

AFFN 被换成 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 解决什么?

Astatic 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 中的实现有什么不同?什么场景收益最大?

AvLLM 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 为什么能加速且不改变输出分布?什么时候反而变慢?

Adecode 时算力闲置,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?

AAWQ/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 为什么通常不跨机?

ATP 切每层矩阵,能降单请求延迟,但每层 2 次 all-reduce(70B 80 层 decode 一步 160 次),延迟敏感、在关键路径上,所以只放 NVLink 域内(通常单机 TP ≤ 8,GB200 NVL72 可到 72)。PP 按层切,只在 stage 边界点对点传 activation,适合跨节点、「一台放不下」的场景,但单请求延迟不降反增。(30 44)

Q: 13. 什么是 PD 分离?为什么要分?代价是什么?

Aprefill(compute-bound)和 decode(memory-bound)放进不同 GPU 池,各自独立扩缩和选并行策略,消除长 prefill 对 decode 的干扰,目标从 throughput 变为 goodput(DistServe)。代价:KV Cache 要经 RDMA 等高速网络从 prefill 池传到 decode 池、两池配比难调、运维复杂;短 prompt 低并发时可能得不偿失。(44)

Q: 14. TTFT、TPOT、throughput、goodput 各是什么?怎么权衡?

ATTFT = 到达到首 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 流出去经过了哪些组件?

AFastAPI → 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 是瓶颈?

ASFT:对话格式的 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 等)在改什么?

ARoPE 把 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 最优是什么意思?为什么现在的模型都「过训练」?

Aloss 随 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 发布时你拿到的四样东西分别对应什么?

Aconfig.json(架构)、*.safetensors(权重)、tokenizer.json(BPE 词表与 merges)、generation_config.json(默认采样参数)。Karpathy 的说法:LLM 本质是「参数文件 + 几百行推理代码」两个文件。(00)

Q: 70B 模型 BF16 权重多大?INT4 呢?

A70B × 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 约多少?

Aforward 每 token 约 2N(每参数一次乘加),backward 约 4N,合计 6N/token。6 × 70e9 × 2e12 ≈ 8.4e23 FLOPs。(03 18)

Q: 为什么训练比推理费显存得多(除了优化器状态)?

Abackward 需要 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 约多少?

A2 × 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 是显存隐藏大户?怎么解决?

Alogits 形状 (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?

A8 × 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?

Amask 要加在 softmax 之前:未来位置分数设为 −inf,exp 后恰好为 0,其余权重自动归一化到 1。若 softmax 之后再把未来位置乘 0,剩余权重之和小于 1;若 softmax 前填 0 而不是 −inf,exp(0)=1 照样分到权重。(10 11)

Q: 为什么要除以 √d_k?

Aq、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 分卡?

Ahead 之间在 attention 计算时互不通信、天然并行;每卡算自己的 head,W_O 按行切,最后一次 all-reduce 汇总。所以 n_head(GQA 下 n_kv_head)通常要能被 TP size 整除。(12)

Q: 为什么 Attention 需要位置编码?GPT-2 的方案有什么硬伤?

AAttention 是置换不变的,只看内容不看顺序。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 好在哪?代价是什么?

Aresidual 主路径保持纯加法,梯度直达,深层训练稳定、对 warmup 不那么敏感(实践中仍用 warmup);代价是 residual stream 尺度随层数增长,必须在最后加一个 final norm。(14)

Q: 为什么 LLM 用 LayerNorm/RMSNorm 而不是 BatchNorm?RMSNorm 省了什么?

ABN 依赖 batch 内其他样本,推理 decode 时行为与训练不一致;LN/RMSNorm 对每个 token 独立计算。RMSNorm 去掉减均值和 β,只按均方根缩放,参数减半(d=4096 时 4096 vs 8192)且更便宜。(14)

Q: SwiGLU 的中间维为什么不是 4d?Llama-2-7B 的 11008 怎么来的?

ASwiGLU 有 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 的天然切入点?

AFFN 对每个 token 独立计算(position-wise):TP 时第一个矩阵按列切、第二个按行切,中间的逐元素激活无需通信,整个 FFN 只要一次 all-reduce。也正因 token 之间互不依赖,可以把不同 token 送进不同的 FFN(Expert),这就是 MoE。(15)

Q: 用 12·L·d² 估算 GPT-2 small 为什么是 124M?

A12 × 12 × 768² ≈ 85M(非 embedding),加上 token embedding 50257 × 768 ≈ 38.6M(与 lm_head 共享)和位置表,约 124M;embedding 占约 31%。(16)

Q: top-p 比 top-k 好在哪?min-p 又好在哪?

Atop-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?

A8B 推理便宜近 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 下有什么问题?

A8 个 KV head 正好与单机 8 卡 TP 对齐,每卡 1 个。MQA 只有 1 个 KV head,TP=8 时每卡都要存完整副本,省下的显存又被复制回来。(19)

Q: 一张 80GB 卡跑 Llama-3-8B,剩 60GB 全给 KV,能放多少 token?

A60GiB ÷ 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?

Alatent 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 怎样?

A1.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?

AK2 用 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 一条请求呢?

A2 × 126 × 8 × 128 × 2B ≈ 504KiB;× 131,072 ≈ 63GiB。(26)

Part 4 · 训练流水线

Q: 预训练数据 pipeline 有哪几步?

A抽正文 → 语言识别 → 启发式规则过滤 → 去重(exact + MinHash/LSH)→ 模型打分过滤 → 按领域配比 → tokenize 成二进制 shard。本质是超大规模 ETL。(27)

Q: 为什么去重不是越狠越好?

AFineWeb 发现跨 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?

ABF16 只有 7 位尾数,lr × grad 这种小更新加到大权重上会被舍入吞掉;在 FP32 上累积更新,再转成 BF16 做计算。(28)

Q: FP8 训练的关键难点和 DeepSeek-V3 的做法?

AFP8 只有 2–3 位尾数,整张量共用一个 scale 易溢出或丢精度;V3 用细粒度 scaling:激活 1×128、权重 128×128 分块,并定期把累加提升到 FP32。(28)

Q: 为什么卡越多 ring all-reduce 并不会线性变慢?

Aring all-reduce = reduce-scatter + all-gather,每卡收发约 2 × 数据量,与卡数 N 基本无关;只有延迟项随 N 增长,大消息下带宽项主导。(29)

Q: 为什么 ZeRO-1/2 的通信量和 DDP 一样?ZeRO-3 多了什么?

ADDP 的 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 每卡模型状态各多少?

AZeRO-3:16 × 70e9 / 64 ≈ 17.5GB。ZeRO-1:权重+梯度 4 × 70 = 280GB 不切 + 12 × 70 / 64 ≈ 13GB,约 293GB,放不下。(29)

Q: ZeRO-3 和 Tensor Parallel 都「切参数」,区别是?

AZeRO-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 规模仅权重多少显存?

Aactor(被训练的策略)、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 和在线采样的?代价?

AKL 约束下最优策略有闭式解,reward 可写成 β·log(π/π_ref),代入 Bradley-Terry 得到偏好对上的分类 loss,只需 policy + reference。代价是 offline,无法探索模型自己的新回答,分布漂移后可能不如 online 方法。(32)

Q: 为什么 RLHF 不能长跑,RLVR 可以?

ARLHF 的奖励是神经网络 RM,分布外打分不可靠,会被 reward hacking;RLVR 的奖励来自规则验证(答案比对、单元测试),很难被钻空子,可以像 AlphaGo 一样一直跑。(32 33)

Q: GRPO 相比 PPO 省了什么?一组 8 个回答全对时梯度是多少?

A省掉与 actor 同规模的 critic(参数、梯度、优化器状态),也不需要 RM;代价是每题采多个回答、整条回答共享一个 advantage。全对时 advantage 全为 0,除 KL 外无贡献——题太简单,要按难度筛数据。(33)

Q: RL infra 为什么难?(至少说三点)

Arollout(推理)占 RL step 大头且长度长尾;训练(FSDP/Megatron)与推理(vLLM/SGLang TP/EP)两套并行布局每步要同步权重;colocate 要 offload/onload,disaggregated 要跨机传权重;还有代码沙箱、答案判定等奖励服务。对策含 partial rollout、异步 RL。(33)

Q: LoRA 的 B 为什么零初始化?两个都零会怎样?

AB=0 保证起始 ΔW=0、不破坏原模型;A 随机保证梯度能流动。都为 0 时 A 和 B 的梯度都为 0,永远学不动。(34)

Q: 8192×8192 矩阵挂 r=8 的 LoRA,可训练参数占多少?

A2 × 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 瓶颈?

A989 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?

Adecode 每步几百个小 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 倍?

A320KB × 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 为什么不越小越好?

A7 个 block、112 个 slot,浪费 12 个。太小会让 block table 变长、访存更碎、kernel 效率下降;太大内部碎片多、难共享。16 是实验结果。(38)

Q: beam search 为什么比 parallel sampling 从 KV 共享中省得多?

Aparallel 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 的区别?

Adynamic 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 选了什么?

AvLLM 论文实验: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 解决什么问题?

Adecode 时 query 长度为 1,按 Q 分块并行度不够;改为沿 KV 长度切给多个 thread block 各算一段,再用 online softmax 的 (m, l) 规则合并。(40)

Q: 两个请求共享前 1000 个 token,vLLM APC(block=16)能复用多少?为什么哈希要含父 block?

A62 个完整 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,接受概率?被拒后从哪采?

Amin(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 多少?

A32B × 0.5B = 16GB,加 scale(约 +3%)约 16.5GB。70B FP8 KV:80 × 8 × 128 × 2 × 1B ≈ 160KB,是 BF16 的一半。(43)

Q: MXFP4 和 NVFP4 的区别?按硬件怎么选量化?

AMXFP4 每 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)大得多?

Adecode 是 memory-bound,需要极大并发 batch 才能把 Expert GEMM 做「胖」;摊到 144 卡后每卡只放约 2 个 routed expert,更多显存留给 KV 和更大 batch。prefill token 多、已 compute-bound,小单元就够。(44)

Q: DeepEP 为什么要两套 kernel?

Anormal kernel 走 NVLink + RDMA 转发、追求高吞吐,用于 prefill;low-latency kernel 纯 RDMA、兼容 CUDA Graph,用于延迟敏感的 decode。(44)

Q: PD 分离后,TTFT 和 TPOT 分别由什么决定?

ATTFT 由 prefill 池(排队 + prefill)加 KV 传输决定;TPOT 由 decode 池的 batch 与负载决定,且不再被新请求的 prefill 打断。(44)

Q: vLLM V1 为什么把 EngineCore 放到独立进程?

APython 有 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 怎么解决「下一步需要上一步采样结果」的依赖?

ACPU 调度 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 又快在哪?

AFSM 当前状态只有唯一合法后续字符串(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?

ATPOT 按请求平均,ITL 按每个相邻 token 间隔统计;P99 ITL 能暴露长 prefill 插队造成的卡顿,这是 chunked prefill 和 PD 分离的动机。(47)

Q: 平均 E2E 8 秒、要撑 5 req/s,系统里平均有多少并发?

ALittle'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 怎么解决?

AAdam 每步对每个权重的改动约等于 lr,宽度为 d 的矩阵乘把 d 个改动累加到输出,模型越宽扰动越大,最优 lr 随宽度下降。μP 按宽度倍数 m 缩放:隐藏层 lr × 1/m、输出 logits × 1/m、attention 用 1/d_head,使最优 lr 不随宽度漂移,可以从约 40M 的代理模型迁移到 6.7B。(18a)

Q: WSD 调度是什么?为什么适合做 scaling 实验?

AWarmup → 恒定 lr 的 Stable 段 → 最后约 10–20% 快速 Decay。从 stable 段任意 checkpoint 分叉做 decay 就得到一个「训到位」的结果,一次主干就能产出多个数据量的数据点;decay 完的 loss 与同长度 cosine 基本持平。(18a)

Q: Perplexity 和 bits-per-byte 是什么关系?为什么要换算?

APerplexity = 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 由哪三部分组成?怎么训练?

AVision 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?

ATorchDynamo 抓计算图,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 与优化

神经网络与语言模型

Token 与 Embedding

Softmax、Loss 与训练循环


Transformer


Modern Arch(现代架构)

Scaling

Attention 变体与 KV 压缩

MoE

长上下文

线性注意力 / SSM / Hybrid

训练技巧与读 config


Training(训练)

数据

精度

分布式训练

Post-training

PEFT


Inference & Infra(推理与系统)

GPU 与性能模型

两阶段与 KV Cache

KV 管理与调度

Attention Kernel

前缀复用

投机解码

量化

分布式推理

引擎架构与指标


Frameworks & Hardware(框架与硬件)

🆕 CS336 补充课(Scaling 实战 · 评测 · 多模态 · Kernel)

回到首页 → README

视频对照表:每课 ↔ Karpathy / 李沐 / 关键论文

用法:想「看视频补一课」时查这张表。所有条目都摘自各课末尾的「延伸」小节,没有新增引用;时间点只在课程里给出时才写。「—」表示该课没有直接对应的视频,Part 3 后半和 Part 5 大多如此,这部分直接读论文 / 官方博客更好。

如果只看 5 个视频

从各课引用里挑出覆盖面最大的 5 个,按推荐观看顺序:

  1. Karpathy · The spelled-out intro to neural networks and backpropagation: building micrograd 覆盖 01–04:导数直觉、计算图、链式法则、+= 梯度累加、Neuron → Layer → MLP。看完就懂「训练 = forward + backward + step」。
  2. 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 的配套。
  3. 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)。从模型到训练工程的一条完整路径。
  4. 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)。这是全课程「全景图」的最佳配套。
  5. 李沐 · 「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