CS336 学习笔记 01:分词、资源核算与 Transformer

本文最后更新于 2026年9月28日 晚上

CS336 入门辅助学习文档 - Transformer 架构、分词、资源核算


📖 写在前面:你现在在哪里?

这门课在讲什么?

CS336 的全称是 "Language Modeling from Scratch"(从零构建语言模型)。

用一句话说:这门课教你怎么从头到尾自己造一个大语言模型(类似 GPT)。

不是调 API,不是微调别人的模型,而是从最底层的分词器开始,到架构设计、到训练系统、到数据处理、到对齐方法——每一层都自己实现。

你需要什么基础?

说实话,这门课有一定难度。但好消息是: - ✅ 你不需要懂深度学习的全部理论 - ✅ 你不需要有训练大模型的经验 - ✅ 你只需要会基本的 Python 编程 - ✅ 有一点线性代数和概率论的基础会更好(没有也没关系,我会用大白话解释)

怎么读这份文档?

  1. 每一节都从"为什么"开始:先理解为什么需要这个东西,再学它是什么
  2. 遇到公式别怕:我会用文字和例子解释每一个符号的含义
  3. 配合原始讲义看:这份文档是辅助,原始讲义(lecture_01.py 等)是权威来源
  4. 动手最重要:看懂了不算会,跟着作业写一遍代码才算真会

📚 术语对照表

英文术语 中文翻译 一句话解释
Token 词元 / 令牌 模型处理的最小单位,可以是一个字、半个词、一个词
Tokenization 分词 / 令牌化 把文字转换成 token 序列的过程
BPE 字节对编码 一种最常用的分词算法
Vocab 词表 所有 token 的集合
Tensor 张量 深度学习里的"多维数组",可以理解为高级版表格
FLOP 浮点运算 一次加法或乘法运算
FLOPs 浮点运算数 计算量的度量单位
MFU 模型浮点利用率 实际算力 / 理论峰值算力
Roofline 屋顶线模型 分析性能瓶颈的工具
Transformer 变换器 大模型的核心架构
Attention 注意力 让模型关注相关信息的机制
RoPE 旋转位置编码 一种给词加位置信息的方法
RMSNorm 均方根归一化 一种让训练更稳定的技术
SwiGLU — 一种常用的激活函数变体
GQA 分组查询注意力 一种加速推理的注意力变体
Pre-norm 前置归一化 归一化放在残差连接之前
Post-norm 后置归一化 归一化放在残差连接之后

第一讲:Tokenization(分词)—— 电脑怎么"读懂"文字?

1.0 前置知识:什么是语言模型?

用生活例子理解

想象你在玩一个"猜下一个字"的游戏:

"今天天气真___"

你会猜什么?大概率是"好"、"不错"、"热"之类的。

语言模型做的就是这件事:根据前面的文字,预测下一个词是什么。

听起来很简单对吧?但就是这么一个简单的任务,训练好了之后,模型就能: - 写文章 - 回答问题 - 写代码 - 翻译语言 - ……几乎所有文字相关的事情

语言模型的数学表达(别怕,很简单)

如果用数学语言说,语言模型就是一个概率分布:

1
P(下一个词 | 前面的词)

翻译成大白话:给定前面已经出现的词,下一个词是某个词的概率有多大。

比如: - P("好" | "今天天气真") = 很高的概率 - P("跑" | "今天天气真") = 很低的概率

关键问题来了

语言模型要处理的是"词",但电脑根本不认识文字啊!电脑只认识 0 和 1。

怎么把文字变成电脑能处理的数字呢? → 这就是 Tokenization(分词)要解决的问题。


1.1 为什么需要 Tokenization?

核心问题:文字 → 数字

电脑只认识数字,所以我们必须把文字转换成数字序列。

比如这句话:

1
"我爱学习"

我们需要把它变成类似这样的东西:

1
[123, 456, 789, 101]

每个数字代表一个"基本单位",这个基本单位就叫 token(词元)。

那怎么分呢?

这就是关键问题了:我们应该以什么为单位来切分文字?

  • 按字?
  • 按词?
  • 按字节?
  • 按某种更聪明的方式?

不同的分法各有优缺点,接下来我们一个一个看。


1.2 四种分词方案:逐个拆解

方案一:字符级分词(Character-level)

做法:每个字符就是一个 token。

1
"我爱学习" → ["我", "爱", "学", "习"] → [1, 2, 3, 4]

优点: - 词表小(中文常用字几千个,英文 26 个字母) - 不会遇到"不认识的字"(所有字都在词表里)

缺点: - 序列太长了!一句话有多少字就有多少个 token - 后面我们会学到,注意力机制的计算量是序列长度的平方 → 序列长了,计算量爆炸 - 字的语义太单薄,模型学起来更难

类比:就像让你用"笔画"来读书——每个字都拆成笔画,你能读,但特别费劲。


方案二:字节级分词(Byte-level)

等等,什么是字节?

你可能听说过 UTF-8、Unicode 这些词。简单说: - 世界上所有文字(中文、英文、 emoji……)都有一个统一的编号,叫 Unicode - 但这些编号在电脑里存储时,需要用某种"编码格式"转成字节(0-255 的数字) - UTF-8 就是最常用的编码格式 - 一个英文字母通常占 1 个字节 - 一个中文字通常占 3 个字节

字节级分词:直接以字节为单位,每个字节是一个 token。

1
2
"abc" → 3 个字节 → 3 个 token
"我爱" → 6 个字节(每个中文 3 字节)→ 6 个 token

优点: - 词表超级小,固定 256 个(因为一个字节就是 0-255) - 绝对不会遇到"不认识的字符"(所有东西都是字节组成的)

缺点: - 序列更长了!比字符级还长 - 计算量更大 - 语义更难学(字节本身没有意义)

类比:就像让你用"二进制"来读书——全是 0101,你根本看不懂。


方案三:词级分词(Word-level)

做法:按"词"来分,每个词是一个 token。

1
"我爱学习人工智能" → ["我", "爱", "学习", "人工智能"] → [1, 2, 3, 4]

优点: - 压缩比高(序列短) - 每个 token 语义丰富

缺点: - 词表太大了!语言里有多少词?根本数不清 - UNK 问题:总会遇到词表里没有的词(比如人名、专业术语、新词),怎么办? - 形态变化问题:英文的 "run" / "running" / "ran" 算同一个词还是不同的词? - 中文更麻烦:中文词之间没有空格,怎么分词本身就是个难题

类比:就像让你用"整本书"为单位来读书——每本书是一个 token,那词表得有多大啊!


方案四:BPE(Byte Pair Encoding,字节对编码)

这是目前大模型最常用的分词方案。

BPE 的思路很聪明:让数据自己决定怎么分。

  • 从最小的单位(字节)开始
  • 反复把最常一起出现的两个 token 合并成一个新 token
  • 常见的组合会被合并成一个 token,少见的组合保持拆分状态

结果: - 常见的词 = 一个 token - 不常见的词 = 几个 token 拼起来 - 完全没见过的词 = 很多字节 token 拼起来

优点: - 词表大小可控(你想设多大就设多大) - 没有 UNK 问题(任何词都能拆成字节表示) - 压缩比不错(常见词压缩得好) - 数据驱动(根据实际语料自动调整)

缺点: - 算法稍微复杂一点(但不难理解) - 分词结果有时候不直观(比如一个词可能被切成奇怪的几部分)

类比:就像学语言的过程——你先学字母(字节),然后学常见的字母组合(前缀、后缀),然后学完整的词。常见的组合你会整体记住,不常见的你会拆开理解。


四种方案对比总结

方案 词表大小 压缩比 UNK 问题 直观程度
字符级 大(几千) 差 无 高
字节级 极小(256) 最差 无 低
词级 极大(数万+) 好 严重 高
BPE 可控 较好 无 中

💡 为什么 BPE 成为主流? 因为它在各方面都比较均衡,没有致命缺点。词表大小你说了算,不会有不认识的词,压缩效果也还不错。


1.3 BPE 算法:手把手教你算

光说概念太抽象了,我们用一个超简单的小例子,一步步把 BPE 算法跑一遍。

准备工作

假设我们的训练数据只有一个词:

1
"lowlower"

(真实场景是整个语料库,但原理一样)

第一步:初始化

  1. 把字符串转成字节(为了简单,我们用字符代替字节演示)
  2. 初始词表 = 所有出现过的字符
1
2
初始序列:l o w l o w e r
初始词表:{l, o, w, e, r} → 5 个 token

第二步:第一次合并

  1. 统计所有相邻 pair 的出现次数:

    • l-o:出现 2 次
    • o-w:出现 2 次
    • w-l:出现 1 次
    • w-e:出现 1 次
    • e-r:出现 1 次
  2. 最常见的 pair 是 l-o 和 o-w(都是 2 次),我们随便选一个,比如 l-o

  3. 创建新 token:lo(编号 6)

  4. 把序列中所有的 l-o 替换成 lo:

    1
    2
    原序列:l o w l o w e r
    替换后:lo w lo w e r

  5. 词表更新:{l, o, w, e, r, lo} → 6 个 token

第三步:第二次合并

  1. 统计新序列中的相邻 pair:

    • lo-w:出现 2 次
    • w-lo:出现 1 次
    • w-e:出现 1 次
    • e-r:出现 1 次
  2. 最常见的是 lo-w(2 次)

  3. 创建新 token:low(编号 7)

  4. 替换:

    1
    2
    原序列:lo w lo w e r
    替换后:low low e r

  5. 词表更新:{l, o, w, e, r, lo, low} → 7 个 token

第四步:继续合并……

可以一直合并下去,直到达到你想要的词表大小,或者没有可以合并的了。

编码(Encode):怎么用训练好的 BPE 分词?

假设我们训练好了上面的 BPE,现在要编码一个新词:"lower"

  1. 先把词拆成单个字符:l o w e r
  2. 按训练时的合并顺序,依次应用每一次合并:
    • 第一次合并(l-o → lo):lo w e r
    • 第二次合并(lo-w → low):low e r
  3. 最终结果:[low, e, r] → 3 个 token

解码(Decode):怎么把 token 还原成文字?

解码就更简单了:

  1. 每个 token 对应一段字节序列
  2. 把所有字节序列拼起来
  3. 用 UTF-8 解码成文字

比如:

1
[low, e, r] → "low" + "e" + "r" → "lower"

💡 关键点:解码是直接查表拼接,不需要算法。编码才需要按顺序应用合并规则。


1.4 压缩比:为什么分词很重要?

什么是压缩比?

1
压缩比 = 原始字节数 / token 数量

简单说:平均一个 token 能代表多少个字节?

  • 压缩比 = 1:一个 token 代表一个字节(最差)
  • 压缩比 = 4:一个 token 代表 4 个字节(很好)

为什么压缩比重要?

因为 Transformer 的注意力计算量是序列长度的平方。

假设一句话有 1000 个字节: - 压缩比 = 1 → 1000 个 token → 注意力计算量 = 1000² = 1,000,000 - 压缩比 = 4 → 250 个 token → 注意力计算量 = 250² = 62,500

差了 16 倍!

所以压缩比越高,序列越短,计算越快,能处理的上下文越长。

但不是越大越好……

压缩比高 → 需要更大的词表 - 词表大 → 最后一层的输出矩阵大 → 参数多 → 计算多 - 词表太大 → 很多 token 出现次数少 → 训练不充分

这是一个权衡:通常 30K-50K 的词表是单语言模型的常见选择。


1.5 实际 BPE 的那些"额外细节"

上面讲的是 BPE 的核心思想。但实际用的 BPE 还有一些额外的技巧:

1. 预分词(Pre-tokenization)

在跑 BPE 之前,先用一些规则把文本粗分一下。

比如 GPT-2 的 BPE 会先用正则表达式把文本切成: - 单词(带空格的) - 数字 - 标点 - 空白字符

为什么要这样做? - 避免跨词合并(比如 "the" 和 "end" 不会被合并成 "theend") - 让分词结果更符合语言直觉

2. 特殊 token(Special Tokens)

有一些特殊的 token 是人为加进去的,不参与 BPE 合并: - <|endoftext|>:文本结束标记 - <|padding|>:填充用的 - 等等……

为什么? 这些 token 有特殊用途,不能被拆分。

3. 高效实现

我们上面讲的"遍历所有 merges 逐个替换"是最简单的实现,但效率不高。

实际的高效实现会用更聪明的数据结构(比如前缀树),不用遍历所有 merges。

💡 作业 1 里会让你实现高效的 BPE,这是很好的练习机会!


1.6 小白常见问题 Q&A

Q:为什么不用中文的"字"做 token?

A:其实中文的字有点像英文的"词素",用字做 token 也是可以的(很多中文模型确实这么做)。但 BPE 的好处是: 1. 对所有语言通用(不用为每种语言设计不同的分词) 2. 能自动学习常见的组合(比如"人工智能"可能就是一个 token) 3. 不会有不认识的字(生僻字可以拆成字节)

Q:BPE 和 WordPiece 有什么区别?

A:这是两种很像的分词算法: - BPE:每次合并出现次数最多的 pair - WordPiece:每次合并能让似然度提升最大的 pair

BERT 用的是 WordPiece,GPT 系列用的是 BPE。实际效果差不多,BPE 更简单所以更流行。

Q:词表大小是怎么选的?

A:主要考虑几个因素: 1. 语言:单语言 30K-50K 就够了,多语言需要 100K-250K 2. 计算资源:词表越大,最后一层参数越多,计算越慢 3. 语料大小:语料越大,可以支撑越大的词表

Q:分词会影响模型效果吗?

A:会,而且影响很大! - 分词不好 → 序列太长 → 计算慢 + 上下文短 - 分词不好 → 语义单元不合理 → 模型难学 - 分词不好 → 数字、代码等特殊内容处理不好

很多人低估了 tokenization 的重要性,但它其实是大模型的基础中的基础。


1.7 第一讲学习路线图

你应该掌握到什么程度?

必须掌握: - ✅ BPE 的核心思想(从字节开始,反复合并最常见 pair) - ✅ BPE 训练、编码、解码的完整流程 - ✅ 压缩比的概念和为什么重要 - ✅ 四种分词方案的优缺点

了解即可: - 📖 语言模型发展历史(有个印象就行) - 📖 课程哲学(理解为什么这门课这么设计)

怎么配合作业 1 学习?

作业 1 会让你实现一个 BPE 分词器,建议: 1. 先看懂算法原理(就是上面这些内容) 2. 自己手写一遍伪代码 3. 再看作业要求,开始写代码 4. 跑测试用例验证

💡 提示:实现 BPE 不难,但实现高效的 BPE 有挑战。先实现正确的版本,再优化速度。


第二讲:资源核算 —— 训练大模型到底要花多少钱?

2.0 前置知识:GPU 是怎么工作的?

为什么深度学习用 GPU?

在讲资源核算之前,我们先搞清楚一个基础问题:为什么训练大模型要用 GPU,不用 CPU?

CPU vs GPU:核心数量的差异

  • CPU:几个到几十个核心,每个核心都很"聪明",能做复杂的事情
  • GPU:几千到上万个核心,每个核心都比较"笨",只会做简单的计算

深度学习需要什么? 大量的矩阵乘法,而矩阵乘法可以拆成很多很多简单的乘法和加法,正好适合 GPU 的"人海战术"。

💡 类比:CPU 像一个有几十个资深工程师的团队,GPU 像一个有几万个流水线工人的工厂。做简单重复的工作(比如矩阵乘法),工人多的工厂快得多;做复杂决策的工作,资深工程师更厉害。

GPU 的两个关键资源

GPU 上有两个最重要的东西:

  1. 计算单元(ALU / Tensor Core):负责做加减乘除运算
    • 衡量指标:FLOP/s(每秒能做多少次浮点运算)
    • 类比:工厂里的工人数量 × 每个工人的速度
  2. 显存(Memory / HBM):存储数据(参数、激活值等)
    • 衡量指标:容量(多少 GB)和带宽(每秒能读多少数据)
    • 类比:仓库的大小和进出货的速度

一个关键洞察

计算快,但数据搬运慢。

GPU 的计算速度提升得很快(每年都在翻倍),但内存带宽的提升慢得多。这就导致: - 有时候 GPU 的计算单元在"等数据",闲着没事干 - 这时候瓶颈不在计算,而在内存

💡 这就是第二讲的核心主题:搞清楚什么时候是计算不够用,什么时候是数据搬运太慢。


2.1 张量:深度学习的基本单位

什么是张量?

张量(Tensor)就是多维数组。

听起来很吓人对吧?其实一点都不难:

维度 名称 生活类比 形状示例
0 维 标量(Scalar) 一个数字 5
1 维 向量(Vector) 一行数字 / 列表 [1, 2, 3] → 形状 (3,)
2 维 矩阵(Matrix) 一张表格 [[1,2],[3,4]] → 形状 (2, 2)
3 维 3D 张量 一摞表格 / 彩色图片 形状 (高, 宽, 3通道)
4 维 4D 张量 一摞彩色图片 / 批量图片 形状 (批量数, 高, 宽, 3通道)

💡 记住:张量的"秩"(rank)就是它有几个维度。秩 1 = 向量,秩 2 = 矩阵,秩 3 = 三维张量……

Transformer 里的张量长什么样?

在 Transformer 中,最常见的张量形状是这样的:

1
(B, S, H, D)

每个字母代表什么?

字母 全称 中文 解释
B Batch size 批量大小 一次处理几句话
S Sequence length 序列长度 每句话有几个 token
H Number of heads 注意力头数 有多少个"注意力视角"
D Head dimension 每个头的维度 每个头的向量有多长

举个具体的例子: - 一次处理 32 句话(B=32) - 每句话 1024 个 token(S=1024) - 有 16 个注意力头(H=16) - 每个头的维度是 64(D=64)

那么注意力计算中的 QKV 张量形状就是:

1
(32, 1024, 16, 64)

💡 不用死记硬背,写代码的时候对照着看就行。看多了自然就记住了。


2.2 数据类型:精度的那些事

为什么有不同的精度?

你可能见过 fp32、fp16、bf16 这些词,它们是什么意思?

简单说:它们是存储一个数字用多少字节的不同格式。

类型 字节数 全称 特点
fp32 4 字节 单精度浮点数 精度高,占内存多
fp16 2 字节 半精度浮点数 精度较低,占内存少,但范围小(容易下溢)
bf16 2 字节 脑浮点数 精度和 fp16 差不多,但范围和 fp32 一样大
fp8 1 字节 8 位浮点数 精度更低,占内存更少
fp4 0.5 字节 4 位浮点数 精度极低,最新技术

为什么 bf16 比 fp16 好?

fp16 和 bf16 都是 2 字节,为什么现在大家都用 bf16?

关键区别在于动态范围: - fp16:能表示的最大数和最小数之间的范围比较小 - 问题:训练时梯度可能很小 → 小到 fp16 表示不了 → "下溢"(underflow)→ 变成 0 → 训练崩了 - bf16:动态范围和 fp32 一样大 - 好处:不会下溢,训练更稳定 - 代价:精度稍低一点(但对深度学习影响不大)

💡 类比:fp16 像一把刻度很细但量程很短的尺子,bf16 像一把刻度稍粗但量程很长的尺子。训练大模型需要长量程,所以 bf16 更好。

混合精度训练(Mixed Precision)

问题:全部用 fp32 太慢太费内存,全部用 bf16 精度不够。

解决方案:混合精度训练 - 参数、激活、梯度:用 bf16(省内存,速度快) - 优化器状态:用 fp32(需要高精度来稳定更新)

这样既快又稳,是现在的标准做法。

💡 记住:bf16 用来存大部分东西,fp32 只在最需要精度的地方用。


2.3 einops:让张量操作不再头疼

为什么传统写法容易出错?

在 PyTorch 中,你可能见过这样的代码:

1
z = x @ y.transpose(-2, -1)

问题来了:-2 和 -1 是什么意思?

答案是:倒数第二个维度和倒数第一个维度。

但这很容易搞混,尤其是维度多的时候(4 维、5 维张量),你得在脑子里数维度,数错了就 bug。

einops 的思路:用名字代替索引

einops 是一个 Python 库,它让你用命名的维度来操作张量,而不是用数字索引。

它有三个核心操作:


1. einsum:广义矩阵乘法

传统写法:

1
z = x @ y.transpose(-2, -1)

einops 写法:

1
z = einsum(x, y, "batch seq1 hidden, batch seq2 hidden -> batch seq1 seq2")

怎么读? - 输入 1:batch seq1 hidden → x 的三个维度分别叫 batch、seq1、hidden - 输入 2:batch seq2 hidden → y 的三个维度分别叫 batch、seq2、hidden - 输出:batch seq1 seq2 → 输出有 batch、seq1、seq2 三个维度 - 没出现在输出里的维度(hidden)→ 会被求和(这就是矩阵乘法的本质)

💡 einsum 的规则:输出中没写的维度,就会被求和掉。


2. reduce:归约操作(求和、求平均等)

传统写法:

1
y = x.sum(dim=-1)  # 对最后一个维度求和

einops 写法:

1
y = reduce(x, "... hidden -> ...", "sum")

怎么读? - 输入:... hidden → 前面不管多少维度(... 表示省略),最后一个维度叫 hidden - 输出:... → 输出没有 hidden 了 - "sum" → 用求和的方式去掉 hidden 维度

还可以求平均、求最大、求最小:

1
2
y = reduce(x, "... hidden -> ...", "mean")  # 求平均
y = reduce(x, "... hidden -> ...", "max") # 求最大


3. rearrange:重排维度(拆分、合并、交换)

这是 einops 最强大的功能,可以灵活地改变张量的形状。

例子 1:拆分维度

1
2
# 把 total_hidden 拆成 heads × hidden1
x = rearrange(x, "... (heads hidden1) -> ... heads hidden1", heads=2)
意思是:最后一个维度叫 total_hidden,把它拆成 heads 和 hidden1 两个维度,heads 的大小是 2。

例子 2:合并维度

1
2
# 把 heads 和 hidden2 合并成 total_hidden
x = rearrange(x, "... heads hidden2 -> ... (heads hidden2)")

例子 3:交换维度顺序

1
2
# 把 seq 和 batch 交换位置
x = rearrange(x, "batch seq hidden -> seq batch hidden")

💡 为什么学 einops? 它不只是让代码更好看,更重要的是它改变了你思考张量操作的方式。用名字思考,比用数字索引思考清晰得多。


2.4 FLOPs:计算量怎么算?

什么是 FLOP?

FLOP = FLoating-point OPeration = 一次浮点运算(一次加法或一次乘法)

FLOPs = FLOP 的复数形式 = 总运算次数(衡量计算量的单位)

FLOP/s = FLOPs per second = 每秒运算次数(衡量速度的单位)

⚠️ 注意:FLOPs(计算量)和 FLOPS(速度)发音一样,但意思完全不同!注意大小写和上下文。


矩阵乘法的 FLOPs 怎么算?

矩阵乘法是深度学习里最常见、最耗时的操作。我们来看看它的计算量是多少。

假设有两个矩阵相乘:

1
A (M × K) × B (K × N) = C (M × N)

计算过程: - 结果 C 有 M × N 个元素 - 每个元素需要 K 次乘法 + K 次加法 = 2K 次运算 - 总运算量 = M × N × 2K = 2 × M × N × K

💡 记住这个公式:矩阵乘法 FLOPs = 2 × M × N × K

举个具体例子: - A 是 100 × 50 的矩阵 - B 是 50 × 200 的矩阵 - FLOPs = 2 × 100 × 50 × 200 = 2,000,000 = 2M FLOPs


训练一个 step 的 FLOPs

训练神经网络时,一个 step(一步更新)包含: 1. 前向传播:输入数据,算出预测结果和损失 2. 反向传播:根据损失,算出每个参数的梯度 3. 参数更新:用梯度更新参数(这部分计算量很小,可以忽略)

前向传播的 FLOPs

对于一个有 N 个参数的模型,输入 batch 有 D 个数据点: - 前向传播 ≈ 2 × N × D FLOPs - (因为每一层基本都是矩阵乘法,加起来差不多是 2 倍参数量)

反向传播的 FLOPs

反向传播需要算两套梯度: - 对输入的梯度(用来往前传) - 对参数的梯度(用来更新参数)

大致是前向传播的 2 倍: - 反向传播 ≈ 4 × N × D FLOPs

合计

1
训练 1 个 step 的 FLOPs ≈ 6 × batch_size × num_parameters

💡 超级重要的经验公式:训练一步 ≈ 6 倍的(批量大小 × 参数数量)

速记口诀:前向 2 倍,反向 4 倍,加起来 6 倍


MFU:模型浮点利用率

MFU = Model FLOPs Utilization = 模型浮点利用率

1
MFU = 实际达到的 FLOP/s / GPU 理论峰值 FLOP/s

简单说:GPU 的计算能力有百分之多少真的用上了?

  • MFU = 100% → GPU 全程满负荷运转(理想状态,几乎达不到)
  • MFU = 50% → 一半时间在计算,一半时间在等数据
  • MFU ≥ 50% 通常已经算很好了

为什么达不到 100%? - 内存带宽不够(数据搬不过来) - 通信开销(多 GPU 之间传数据) - 一些小操作(激活函数、归一化)是内存受限的


2.5 算术强度:这才是性能的核心

这是第二讲最重要、最核心的概念,请认真读。

用"工厂流水线"来理解

想象一个工厂: - 仓库(内存):存放原材料和成品 - 车间(计算单元):加工原材料 - 传送带(内存带宽):在仓库和车间之间搬运东西

生产一个产品的过程: 1. 从仓库运原材料到车间(数据搬运) 2. 车间加工(计算) 3. 把成品运回仓库(数据搬运)

问题:工厂的瓶颈在哪里? - 如果传送带很慢,车间经常在等原材料 → 瓶颈在搬运(内存受限) - 如果车间加工很慢,传送带闲着没事干 → 瓶颈在加工(计算受限)


两个关键指标

1. 加速器强度(Accelerator Intensity)

1
2
加速器强度 = GPU 峰值计算速度 / 内存带宽
= 峰值 FLOP/s / 字节每秒

这是 GPU 本身的属性,代表:每搬运 1 字节数据,GPU 能做多少次计算。

  • 加速器强度高 → GPU 计算能力很强,相对来说搬运比较慢
  • 加速器强度低 → GPU 计算能力一般,搬运相对还可以

2. 算术强度(Arithmetic Intensity)

1
2
算术强度 = 一个操作的计算量 / 这个操作需要搬运的数据量
= FLOPs / 字节访问量

这是操作本身的属性,代表:每读 1 字节数据,需要做多少次计算。

  • 算术强度高 → 计算多,数据少(比如大矩阵乘法)
  • 算术强度低 → 计算少,数据多(比如逐元素操作)

怎么判断瓶颈?

很简单,比较两个值:

关系 瓶颈 含义
算术强度 < 加速器强度 内存受限 数据搬不过来,计算单元在等
算术强度 > 加速器强度 计算受限 计算忙不过来,数据搬运在等

💡 类比: - 算术强度 = 每个产品需要加工的时间 - 加速器强度 = 传送带的搬运速度 / 车间的加工速度 - 如果产品加工很快(算术强度低),车间经常等原材料 → 搬运是瓶颈 - 如果产品加工很慢(算术强度高),原材料堆在车间门口 → 加工是瓶颈


Roofline 模型:一张图看懂

把上面的关系画成图,就是 Roofline 模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
性能 (FLOP/s)
^
| ┌─────────────── 计算受限(屋顶)
| /
| /
| / ← 斜率 = 内存带宽
| /
| /
| /
| / ← 内存受限(斜坡)
|/
+------------------------> 算术强度
↑
拐点 = 加速器强度

怎么读这张图? - 左边(斜坡):算术强度低 → 内存受限 → 性能随算术强度线性增长 - 右边(平顶):算术强度高 → 计算受限 → 性能达到峰值,不再增长 - 拐点:刚好从内存受限变成计算受限的位置,位置 = 加速器强度


常见操作的算术强度

以 H100 GPU 的 bf16 精度为例:

操作 算术强度 瓶颈 解释
ReLU ~0.25 内存受限 每个元素只做一次比较,计算极少
GELU ~5 内存受限 计算比 ReLU 多,但还是不多
向量点积 ~0.5 内存受限 读两个向量,算一个数
矩阵-向量乘 ~1 内存受限 推理时的主要操作
矩阵-矩阵乘 ~n/3 计算受限 n 足够大时,计算量很大

💡 关键洞察 1:逐元素操作(ReLU、GELU、加法……)都是内存受限的。它们的 FLOPs 很少,但需要读写大量数据。

关键洞察 2:大矩阵乘法是计算受限的。数据量不大,但计算量巨大,能把 GPU 喂饱。

关键洞察 3:为什么 ReLU 和 GELU 速度差不多?因为它们都是内存受限的,计算再复杂也不影响速度——反正计算单元在等数据。


为什么推理比训练难优化?

这是一个非常重要的实际问题。

训练时

  • 输入是一整批句子(batch size 大)
  • 一次算一整批 → 大矩阵乘法 → 计算受限 → GPU 利用率高

推理时(生成文本时)

  • 每次只生成一个 token
  • 是矩阵-向量乘(不是矩阵-矩阵乘)
  • 算术强度低 → 内存受限 → GPU 利用率低

💡 这就是为什么: - 训练时 GPU 利用率能到 50%+ - 推理时(batch size=1)GPU 利用率可能只有 10% 甚至更低 - 推理优化的核心就是想办法提高算术强度(比如 batch 推理、KV Cache 优化等)


2.6 训练资源核算:实际算一算

理论讲完了,我们来实际算一算:训练一个大模型到底需要多少显存?需要多长时间?

显存里都存了什么?

训练一个模型时,GPU 显存里需要存这些东西:

组件 每参数字节数(混合精度) 说明
参数 2 字节(bf16) 模型的权重
梯度 2 字节(bf16) 反向传播算出来的梯度
优化器状态 8 字节(fp32) Adam 需要存一阶矩和二阶矩,各 4 字节
激活值 2 字节 × 层数 × 批量 前向传播的中间结果,反向传播要用

前三项加起来:2 + 2 + 8 = 12 字节 / 参数

💡 Adam 为什么占这么多内存?

Adam 优化器需要为每个参数保存两个值: - m(一阶矩,类似动量):4 字节 fp32 - v(二阶矩,类似梯度平方的平均):4 字节 fp32

加起来就是 8 字节,比参数本身还大 4 倍!


例子:8 张 H100 能训多大的模型?

已知条件: - 每张 H100 有 80GB 显存 - 共 8 张 → 总显存 = 8 × 80GB = 640GB - 每参数开销 = 12 字节(参数 + 梯度 + 优化器状态)

计算:

1
2
3
最大参数量 = 总显存 / 每参数开销
= 640GB / 12B
≈ 530 亿参数 = 53B

⚠️ 注意:这是上限!实际还要考虑: - 激活值内存(取决于 batch size、序列长度、层数) - 一些临时缓冲区 - 通信开销

所以实际能训的模型会比 53B 小一些。


训练时间怎么估算?

公式:

1
2
3
总 FLOPs = 6 × 参数数量 × token 数量
每日 FLOPs = 峰值 FLOP/s × MFU × GPU 数量 × 86400(一天的秒数)
训练天数 = 总 FLOPs / 每日 FLOPs

举个例子: - 模型大小:70B 参数 - 训练数据:15T tokens - GPU:1024 张 H100 - MFU:50% - H100 bf16 峰值:约 989 TFLOP/s

计算:

1
2
3
总 FLOPs = 6 × 70e9 × 15e12 = 6.3 × 10^24
每日 FLOPs = 989e12 × 0.5 × 1024 × 86400 ≈ 4.38 × 10^22
训练天数 = 6.3e24 / 4.38e22 ≈ 144 天

💡 所以训练一个 70B 的模型,用 1024 张 H100,大概需要 5 个月左右。

这还只是训练时间,不包括调试、重试、数据准备等时间。


2.7 内存不够怎么办?

训练大模型时,显存不够是最常见的问题。有什么办法?

方法一:梯度累积(Gradient Accumulation)

问题:大 batch size 训练更稳定,但 batch 大了激活值内存就大,容易 OOM(显存不够)。

方案:把一个大 batch 拆成几个小 batch(叫 micro batch),分别算梯度,累积起来,最后一起更新。

1
2
3
4
5
6
7
8
9
传统方式:
大 batch → 前向 → 反向 → 更新参数 → 清零梯度

梯度累积:
micro batch 1 → 前向 → 反向 → 累积梯度(不清零)
micro batch 2 → 前向 → 反向 → 累积梯度(不清零)
...
micro batch N → 前向 → 反向 → 累积梯度
→ 更新参数 → 清零梯度

效果: - 激活值内存:原来的 1/N(因为每次只存一个 micro batch 的激活) - 总计算量:不变(还是算同样多的数据) - 训练效果:和大 batch 基本一样

💡 梯度累积是用时间换空间:花更多时间(多步前向反向),省内存。


方法二:激活检查点(Activation Checkpointing)

问题:反向传播需要用到前向传播的所有中间激活值 → 这些都存在显存里 → 占很多内存。

方案:前向传播时,不存所有层的激活值,只存一部分(检查点)。反向传播时,遇到缺失的激活值,就从最近的检查点重新算一遍。

1
2
3
4
5
6
7
传统方式:
前向:保存每一层的激活 → 内存 O(L)
反向:直接用保存的激活 → 无重计算

激活检查点(每 √L 层存一个):
前向:只保存检查点的激活 → 内存 O(√L)
反向:缺失的激活重新计算 → 增加约 33% 的计算量

权衡:用计算换内存。

  • 极端情况 1:保存所有层激活 → 内存 O(L),无重计算
  • 极端情况 2:不保存任何激活 → 内存 O(1),计算 O(L²)
  • 常见做法:每几层存一个检查点 → 内存和计算的平衡

💡 经验法则:激活检查点通常增加约 33% 的计算时间,但能省一半以上的激活内存。


2.8 小白常见问题 Q&A

Q:FLOPs 越少速度越快吗?

A:不一定! 这是很多新手的误区。

FLOPs 只是计算量,但实际速度还取决于: - 内存访问量(是不是内存受限) - 并行度(能不能充分利用 GPU) - 硬件特性(Tensor Core 等)

两个 FLOPs 相同的操作,速度可能差好几倍。

正确的衡量方式:直接测时间,不要只看 FLOPs。

Q:为什么 ReLU 和 GELU 速度差不多?

A:因为它们都是内存受限的操作。

ReLU 计算很简单(一个比较),GELU 计算复杂一些(指数、乘法等)。但它们都需要读写同样多的数据,而数据搬运才是瓶颈。

就像两个工人,一个做简单的活,一个做复杂的活,但如果他们都在等原材料,那么干活速度的差异就不重要了——反正都在等。

Q:MFU 为什么达不到 100%?

A:因为: 1. 不是所有操作都是计算受限的:激活函数、归一化等是内存受限的,这些时候 GPU 在等数据 2. 数据搬运需要时间:即使是计算受限的操作,也需要先把数据搬进来 3. 多 GPU 通信:分布式训练时,GPU 之间需要同步梯度,这也需要时间 4. 其他开销:Python 开销、调度开销等

MFU 能到 50% 以上就已经很不错了。

Q:为什么不直接用 fp32 训练?又稳又准?

A:因为太慢太费内存了。

  • fp32 是 4 字节,bf16 是 2 字节 → 显存差一倍
  • fp32 计算速度也比 bf16 慢(H100 上 Tensor Core 对 bf16 优化更好)

混合精度训练是"既要快又要稳"的折中方案。

Q:梯度累积会不会影响训练效果?

A:基本不会。只要累积的步数不是特别多,效果和大 batch 几乎一样。

但有一个小区别:BatchNorm 等依赖 batch 统计量的层会有影响(但 Transformer 里不用 BatchNorm,用 LayerNorm,所以没问题)。


2.9 第二讲学习路线图

你应该掌握到什么程度?

必须掌握: - ✅ 矩阵乘法 FLOPs 怎么算:2 × M × N × K - ✅ 训练一个 step 的 FLOPs:6 × batch_size × num_parameters - ✅ 算术强度的概念 - ✅ 怎么判断内存受限还是计算受限 - ✅ 训练内存的四大组成部分 - ✅ 梯度累积和激活检查点的原理

理解即可: - 📖 einops 的用法(用多了自然会) - 📖 Roofline 模型的细节 - 📖 各种精度的具体区别

怎么配合作业 1 学习?

作业 1 里有资源核算的题目,建议: 1. 先理解上面的公式 2. 自己手算几个例子 3. 再写代码验证

💡 提示:资源核算是"纸上谈兵"的技能,但非常重要。做项目之前先算一算需要多少资源,能避免很多坑。


第三讲:Transformer 架构 —— 大模型的骨架长什么样?

3.0 前置知识:神经网络基础

在讲 Transformer 之前,我们先快速回顾一下神经网络的基本概念。如果你已经很熟悉了,可以跳过这一节。

什么是神经网络?

最简单的神经网络:一层神经元。

1
输入 → 乘以权重 → 加偏置 → 激活函数 → 输出

用公式说:

1
输出 = 激活函数(输入 × 权重 + 偏置)

多层神经网络:把很多层叠起来。

1
输入 → 第1层 → 第2层 → ... → 第N层 → 输出

每一层都做同样的事情:线性变换 + 激活函数。

💡 类比:神经网络就像一个多级加工流水线,原材料(输入)经过一道道工序(每一层),最后变成成品(输出)。每道工序都在做"筛选和变换"。

什么是前向传播和反向传播?

  • 前向传播:输入数据,一层一层往前算,得到输出和损失 → 这是"预测"的过程
  • 反向传播:根据损失,从后往前算每个参数的梯度 → 这是"学习"的过程
  • 参数更新:用梯度调整参数,让损失变小 → 这是"进步"的过程

💡 类比:前向传播是"做题",反向传播是"对答案看哪里错了",参数更新是"订正错题"。

什么是残差连接(Residual Connection)?

问题:神经网络层数多了之后,训练会变得困难(梯度消失/爆炸)。

解决方案:残差连接。

1
2
普通层:输出 = 层(输入)
残差层:输出 = 输入 + 层(输入)

就是把输入直接加到输出上,形成一条"捷径"。

为什么有用? - 梯度可以沿着捷径直接传回去,不容易消失 - 层只需要学"残差"(输入和输出的差),更容易学

💡 类比:就像考试的时候,你先写一个基础答案(输入),然后在旁边补充修改(层的输出)。即使补充修改错了,基础答案还在,不会完全跑偏。


3.1 Transformer 整体结构:先看全貌

一句话总结

Transformer 就是一堆 Transformer Block 叠起来。

每个 Transformer Block 里有两个主要部分: 1. 自注意力层(Self-Attention):让每个词"看"其他词,决定关注谁 2. 前馈网络层(FFN):对每个词单独做非线性变换

再加上: - 残差连接:每一层都有 - 归一化(Norm):让训练更稳定

一层 Transformer Block 长什么样?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
输入
↓
┌─────────────────────┐
│ 归一化(Norm) │
│ ↓ │
│ 自注意力(Attention)│
│ ↓ │
│ 残差连接(加回输入) │ ← 第一条残差
└─────────────────────┘
↓
┌─────────────────────┐
│ 归一化(Norm) │
│ ↓ │
│ 前馈网络(FFN) │
│ ↓ │
│ 残差连接(加回输入) │ ← 第二条残差
└─────────────────────┘
↓
输出

💡 注意:上面画的是 Pre-norm(归一化在子层前面),这是现代 Transformer 的标准做法。原始 Transformer 是 Post-norm(归一化在残差后面)。

数据是怎么流动的?

假设输入是一句话,有 N 个 token,每个 token 是一个 D 维的向量:

  1. 注意力层:每个 token 都和所有 token "交流"一下,更新自己的表示
    • 输入形状:(N, D)
    • 输出形状:(N, D)
    • 特点:token 之间有信息交互
  2. FFN 层:每个 token 自己做一次非线性变换
    • 输入形状:(N, D)
    • 输出形状:(N, D)
    • 特点:token 之间独立,各自变换

💡 类比:注意力层像"小组讨论"——每个人都听听别人说什么,然后更新自己的想法;FFN 层像"独立思考"——每个人自己消化一下刚才讨论的内容。


3.2 归一化(Norm):让训练稳定的秘诀

为什么需要归一化?

训练深度神经网络时,每一层的输入分布可能会不断变化(这个现象叫 "Internal Covariate Shift")。这会导致: - 训练不稳定 - 学习率不能太大 - 深层网络很难训

归一化就是把每一层的输入"拉"到一个合适的范围,让训练更稳定。

💡 类比:就像做饭时控制火候——火太大容易糊,火太小熟得慢。归一化就是把"火"调到合适的大小。


Pre-norm vs Post-norm:放哪里很重要

归一化放在哪里?有两种选择:

Post-norm(原始 Transformer)

1
输出 = LayerNorm(输入 + 子层(输入))

归一化在残差连接之后,在残差流上。

Pre-norm(现代标准)

1
输出 = 输入 + 子层(LayerNorm(输入))

归一化在子层内部,不在残差流上。

为什么 Pre-norm 更好?

  1. 梯度衰减更少:梯度可以沿着残差连接直接传回去,不会被归一化"削弱"
  2. 梯度尖峰更少:训练更稳定,不容易出现突然的梯度爆炸
  3. 不需要 warmup:Post-norm 通常需要学习率 warmup(慢慢提高学习率),Pre-norm 不用
  4. 大网络更稳定:模型越大,Pre-norm 的优势越明显

💡 直觉理解: - Post-norm:残差流上有归一化 → 梯度回传时要经过归一化 → 梯度被"削弱" - Pre-norm:残差流是"干净"的 → 梯度直接回传 → 梯度不消失

就像一条河,Post-norm 在河上建了很多水坝(梯度会被削弱),Pre-norm 把水坝移到支流上(主流畅通无阻)。

新趋势:Double Norm / Non-residual Postnorm

有些人想:既然残差流里放 Norm 不好,那我在残差流外面再加一个 Norm 呢?

  • 思路:Pre-norm(残差流内)+ 额外的 Post-norm(残差流外)
  • 代表模型:Grok、Gemma 2、OLMo 2

效果据说更好,但还不是主流。


LayerNorm vs RMSNorm:简化也能行

LayerNorm(层归一化)

1
y = (x - mean(x)) / sqrt(var(x) + ε) * γ + β

做了什么: 1. 减去均值(让数据的均值为 0) 2. 除以标准差(让数据的方差为 1) 3. 乘以可学习的缩放因子 γ 4. 加上可学习的偏置 β

RMSNorm(均方根归一化)

1
y = x / sqrt(mean(x²) + ε) * γ

做了什么: 1. 除以均方根(只归一化"大小",不减均值) 2. 乘以可学习的缩放因子 γ 3. 没有偏置 β

区别:RMSNorm 比 LayerNorm 简单: - 不减均值 - 不加偏置 - 少了一些计算

为什么 RMSNorm 更好?

  1. 更快:
    • 更少的操作(不用算均值)
    • 更少的参数(没有 bias)
    • 更少的内存访问(数据搬运少)
  2. 效果差不多:
    • 实践中 RMSNorm 和 LayerNorm 性能差不多
    • 甚至有时候 RMSNorm 更好

💡 为什么 RMSNorm 也能行?

归一化的主要目的是控制"尺度"(数值大小),而不是"中心"(均值位置)。 RMSNorm 虽然不减均值,但它控制了尺度,这就够了。

就像你只需要控制车速(尺度),不需要关心车在车道的哪个位置(均值)。

一个重要的洞察:FLOPs 不是一切

你可能会想:RMSNorm 比 LayerNorm 少了几个操作,能快多少?

答案是:可能快不少,但不是因为 FLOPs 少了。

因为归一化操作是内存受限的(第二讲学过的!),瓶颈在数据搬运,不在计算。RMSNorm 少了一些参数和中间结果,需要搬运的数据少了,所以更快。

💡 这再次印证了第二讲的结论:很多时候,优化的关键不是减少计算量,而是减少数据搬运。


去掉偏置项(Bias Terms)

现代 Transformer 的另一个趋势:线性层和归一化都去掉偏置项。

为什么? 1. 省内存:少了偏置参数,数据搬运少 2. 更稳定:有些研究表明去掉偏置训练更稳定 3. 效果差不多:去掉之后性能没什么下降

💡 趋势:越来越多的新模型去掉了偏置项。这是一个"没什么坏处,还有点好处"的改动。


归一化总结

方面 现代共识 原因
Pre vs Post norm Pre-norm 梯度传播更好,训练更稳定
Layer vs RMS norm RMSNorm 更快,效果相当
偏置项 去掉 省内存,更稳定

3.3 激活函数与 FFN:神经网络的"灵魂"

为什么需要激活函数?

问题:如果神经网络里只有线性变换(矩阵乘法),那么不管多少层,整体还是一个线性变换。

为什么? 因为线性变换的组合还是线性的。

那有什么问题? 线性模型能表示的东西很有限,学不了复杂的模式。

解决方案:在每一层之间加一个非线性激活函数。

有了非线性,多层神经网络才能表示复杂的函数,才能学到复杂的模式。

💡 类比:如果没有激活函数,神经网络就像只会做"加减乘除"的计算器,功能有限。有了激活函数,它就有了"弯曲"的能力,能拟合各种复杂的曲线。


常见的激活函数

ReLU(Rectified Linear Unit)

1
ReLU(x) = max(0, x)
  • 正数不变,负数变 0
  • 最简单、最经典的激活函数
  • 代表模型:原始 Transformer、T5、OPT

GELU(Gaussian Error Linear Unit)

1
GELU(x) ≈ x * sigmoid(1.702 * x)
  • 比 ReLU 平滑,有一点"渐变"
  • 代表模型:GPT-1/2/3、BLOOM

SwiGLU / GeGLU(门控线性单元)

这个稍微复杂一点,我们单独讲。


门控线性单元(GLU 变体)

普通 FFN 长什么样?

1
FFN(x) = 激活函数(x × W₁) × W₂

就是两个线性层中间夹一个激活函数。

形状变化:

1
d_model → d_ff → d_model
(d_ff 通常是 d_model 的 4 倍)

门控 FFN 长什么样?

1
FFN_Gated(x) = (激活函数(x × W₁) ⊙ x × V) × W₂

多了什么? - 多了一个线性变换 V(门控信号) - 门控信号和值逐元素相乘(⊙ 表示逐元素乘)

门控是什么意思? - 门控信号(x × V)决定了"哪些信息通过" - 值(激活函数(x × W₁))是"要通过的信息" - 两者相乘 = 用门控来"过滤"值

💡 类比:门控就像一个阀门,阀门开大一点,信息就多过一点;阀门关小一点,信息就少过一点。阀门的大小由输入自己决定。

常见的 GLU 变体

名称 激活函数 代表模型
ReGLU ReLU 较少用
GeGLU GELU T5 v1.1、Gemma 2/3/4、Phi-3
SwiGLU Swish(x·sigmoid(x)) LLaMA 1/2/3、PaLM、Mistral、OLMo

SwiGLU 是目前最流行的,2023 年后的新模型基本都用 SwiGLU。

注意:GLU 的 ff_dim 要小一点

因为 GLU 多了一个投影矩阵(V),参数会变多。为了保持参数量差不多,GLU 变体的 d_ff 通常会缩小到原来的 2/3。

1
2
普通 FFN:d_ff = 4 × d_model
GLU FFN:d_ff ≈ (8/3) × d_model ≈ 2.67 × d_model

💡 为什么是 2/3? 因为多了一个矩阵,参数多了 50%。为了总参数差不多,d_ff 缩小到 2/3。


GLU 有效吗?

答案:有效,而且效果挺一致的。

多篇论文都表明 GLU 变体比普通 FFN 好: - Shazeer 2020 的论文系统比较了各种激活函数 - 后续很多工作都验证了这一点

虽然不是必须的(GPT-3 没用 GLU 也很好),但 2023 年后几乎所有新模型都用 SwiGLU 了。

💡 为什么 GLU 有效?

直觉上,门控机制让网络有了"选择"的能力——它可以决定哪些信息保留、哪些信息丢弃。这种灵活性可能是性能提升的原因。

但说实话,我们还没有完全理解为什么 GLU 这么有效。很多深度学习的发现都是"先有效,后理解"。


串行 vs 并行层

串行(标准做法)

1
2
x = x + Attention(x)
x = x + FFN(x)

先算注意力,再算 FFN,一步一步来。

并行

1
x = x + Attention(x) + FFN(x)

注意力和 FFN 同时算,然后一起加回去。

为什么并行可能更快? - 两个大矩阵乘法可以融合成一个 - LayerNorm 可以共享

代表模型:GPT-J、PaLM、GPT-NeoX、Cohere Command A

现状:大多数模型还是用串行的。并行虽然可能更快,但实现复杂一些,而且效果不一定更好。


3.4 位置编码:模型怎么知道"第几个词"?

为什么需要位置信息?

Transformer 的注意力层本身是位置无关的。

什么意思?就是说,如果你把句子里的词打乱顺序,注意力计算的结果(从集合的角度看)是一样的。

但语言是有顺序的啊!"我爱你"和"你爱我"意思完全不同。

所以我们需要给模型提供位置信息,让它知道每个词在句子中的位置。

💡 类比:注意力层就像一个小组讨论,每个人都能听到所有人的话,但它不知道谁先谁说的。位置编码就是给每个人发一个"序号牌",让大家知道发言顺序。


四种位置编码方案

1. Sinusoidal(正弦余弦位置编码)

  • 做法:用正弦和余弦函数生成位置向量,加到词向量上
  • 特点:固定的,不需要学习
  • 代表:原始 Transformer

2. Absolute(绝对位置编码)

  • 做法:每个位置有一个可学习的向量,加到词向量上
  • 特点:简单直接,但不能泛化到训练时没见过的长度
  • 代表:GPT-1/2/3、OPT

3. Relative(相对位置编码)

  • 做法:在注意力计算中加入相对位置的偏置
  • 特点:只关心两个词的相对距离,不关心绝对位置
  • 代表:T5、Gopher、Chinchilla

4. RoPE(旋转位置编码)

  • 做法:对 query 和 key 向量进行"旋转",旋转角度取决于位置
  • 特点:真正的相对位置编码,效果好
  • 代表:GPT-J、PaLM、LLaMA(2024 年后的主流)

RoPE 详解:用旋转来编码位置

这是现在最流行的位置编码方案,我们详细讲一讲。

核心思想

我们希望:两个向量的内积(注意力分数)只依赖它们的相对位置,而不依赖绝对位置。

数学表达:

1
f(x, i) · f(y, j) = g(x, y, i-j)
- f(x, i) 表示位置 i 处的向量 x 经过位置编码后的结果 - 它们的内积只和 x、y、以及相对位置 i-j 有关

怎么实现?用旋转!

关键数学性质:内积在旋转下是不变的。

什么意思?如果你把两个向量都旋转同样的角度,它们的内积不变。

但是……如果两个向量旋转不同的角度呢?它们的内积就会变化,而且变化量只取决于角度差。

RoPE 的思路: - 对位置 i 的向量,旋转 i × θ 度 - 对位置 j 的向量,旋转 j × θ 度 - 它们的内积变化只取决于 (i-j) × θ → 只取决于相对位置!

💡 几何直觉: 想象两个向量,它们之间有一个夹角。 - 如果你把它们都旋转同样的角度,夹角不变 → 内积不变 - 如果你旋转不同的角度,夹角就变了 → 内积也变了 - 夹角的变化量 = 两个旋转角度的差

RoPE 就是利用了这个性质:每个位置旋转不同的角度,让内积带上位置信息。

具体怎么旋转?

把向量的坐标两两配对,每一对在 2D 平面上旋转。

比如一个 4 维向量 [x₁, x₂, x₃, x₄]: - 把 (x₁, x₂) 当作一个 2D 向量,旋转一下 - 把 (x₃, x₄) 当作另一个 2D 向量,旋转一下

每一对的旋转角度不同(低频的旋转慢,高频的旋转快),这和正弦位置编码的思路有点像。

怎么实现?

用正弦和余弦来实现旋转:

1
旋转后的向量 = x * cos(位置 × 频率) + 旋转90度(x) * sin(位置 × 频率)

💡 RoPE 和正弦位置编码的区别: - 正弦位置编码:相加(把位置向量加到词向量上) - RoPE:相乘(旋转,相当于乘了一个旋转矩阵)

区别很大!相加会有交叉项(不是纯相对位置),相乘(旋转)是纯相对位置。


3.5 注意力变体:让推理更快

什么是 KV Cache?

在讲注意力变体之前,先理解一个重要概念:KV Cache。

推理时的问题

当模型生成文本时,是一个 token 一个 token 生成的: - 第 1 步:输入 "今天天气" → 生成 "真" - 第 2 步:输入 "今天天气真" → 生成 "好" - 第 3 步:输入 "今天天气真好" → 生成 "!"

每一步都要重新计算所有 token 的 K 和 V 吗?

不用! 前面的 token 的 K 和 V 是不变的,可以缓存起来。

这就是 KV Cache:把之前算好的 K 和 V 存起来,新的 step 只算新 token 的 K 和 V,然后拼到缓存后面。

KV Cache 为什么重要?

因为推理时的主要开销就在 KV Cache 的读写上。

KV Cache 越大 → 需要读写的数据越多 → 越慢 → 越占显存

💡 KV Cache 大小: - 大小 = 2 × 层数 × 序列长度 × 头数 × 每头维度 × 字节数 - 序列越长,KV Cache 越大 - 所以长上下文推理特别费显存


MQA / GQA:减少 KV 头数

问题:KV Cache 太大了,推理慢还费显存。

思路:能不能减少 KV 的头数?

MQA(Multi-Query Attention,多查询注意力)

  • Q 还是有很多个头
  • K 和 V 只有 1 个头(所有 Q 头共享同一组 KV)

效果: - KV Cache 大幅减小(只有原来的 1/H,H 是头数) - 推理速度大幅提升 - 代价:PPL(困惑度)略有上升(数值越大越不好,即生成质量略降)

GQA(Grouped-Query Attention,分组查询注意力)

  • 折中方案
  • 把 Q 头分成几组,每组共享一组 KV 头
  • 比如 32 个 Q 头,分成 8 组,每组 4 个 Q 头共享 1 组 KV → 共 8 组 KV

效果: - KV Cache 减小(原来的 1/4) - 推理速度提升 - PPL 几乎不上升(质量接近完整的多头注意力,明显好于 MQA)

代表模型:LLaMA 2、Mistral 等现代模型基本都用 GQA

💡 类比: - MHA(普通注意力):每个老师有自己的教材 → 教材很多,占地方 - MQA:所有老师共用一套教材 → 教材最少,但可能不够用 - GQA:几个老师共用一套教材 → 折中,效果好还省地方

MLA(Multi-head Latent Attention)

DeepSeek V2 提出的,进一步压缩 KV 的维度,更激进的方案。


稀疏 / 滑动窗口注意力

问题:全注意力的计算量是 O(n²),n 是序列长度。序列长了之后,计算量爆炸。

思路:不是每个 token 都需要关注所有 token,能不能只关注一部分?

滑动窗口注意力(Sliding Window Attention, SWA)

  • 每个 token 只关注它前面一个窗口内的 token
  • 计算量从 O(n²) 降到 O(n × window_size)
  • 窗口大小通常是几千(比如 4096)

问题:只有局部信息,缺少长距离依赖。

混合注意力(当前主流)

  • 交替使用全注意力和滑动窗口注意力
  • 比如:每 4 层中有 1 层是全注意力,其他 3 层是 SWA
  • 全注意力层负责长距离信息
  • SWA 层负责局部信息

代表模型: - Cohere Command A - LLaMA 4 - Gemma 3/4 - OLMo 3

💡 趋势:长上下文模型基本都用混合注意力了。全注意力太贵,纯滑动窗口又不够,混合是折中。


3.6 超参数:那些"经验法则"是怎么来的?

Transformer 有很多超参数可以调。经过这么多年的实践,大家总结出了一些"共识"。

我们一个一个来看。


1. FFN 比例:d_ff / d_model

问题:FFN 层的中间维度应该是输入维度的几倍?

经验法则

  • 普通 FFN:d_ff ≈ 4 × d_model
  • GLU 变体:d_ff ≈ (8/3) × d_model ≈ 2.67 × d_model

实际模型的数据

模型 d_ff / d_model
PaLM 4
Mistral 7B 3.5
LLaMA-2 70B 3.5
LLaMA 70B 2.68
Qwen 14B 2.67
DeepSeek 67B 2.68
Yi 34B 2.85
T5 v1.1 2.5
T5 11B 64(极端例外!)

大多数模型在 2.5-4 之间。

为什么是这个范围?

Kaplan 等人 2020 年的研究表明:在 1-10 倍之间,这个超参数对性能影响不大——有一个很宽的"盆地",在这个范围内效果都差不多。

💡 T5 的 64 倍是怎么回事?

T5 11B 是一个很激进的设置,FFN 维度是模型维度的 64 倍!

但 T5 后来出了 v1.1 版本,改成了 2.5 倍(用 GeGLU),效果更好。

所以 64 倍大概率是次优的,只是 T5 当时的选择而已。


2. Head 比例:num_heads × head_dim / d_model

问题:注意力头数和每个头的维度应该怎么设?

经验法则

1
num_heads × head_dim ≈ d_model  →  比例 ≈ 1

就是说,所有头的维度加起来差不多等于模型维度。

实际模型的数据

模型 比例
GPT-3 1
T5 v1.1 1
LLaMA 2 1
Qwen 3.5 27B 1.2
PaLM 1.48
LaMDA 2
T5 16(极端例外!)

大多数模型比例在 1 左右,Google 的一些模型是例外。

💡 注意:这个"1 倍法则"的实验验证其实不多,更多是一种习惯和惯例。没人系统地研究过改变这个比例会怎样。


3. 宽深比(Aspect Ratio):d_model / num_layers

问题:模型应该"胖"一点(宽而浅)还是"瘦"一点(窄而深)?

定义

1
宽深比 = d_model / num_layers
  • 比值大 → 宽而浅
  • 比值小 → 窄而深

实际模型的数据

模型 d_model / num_layers
BLOOM 205
T5 v1.1 171
PaLM 540B 156
GPT-3 / OPT / Mistral / Qwen 128
LLaMA / LLaMA 2 102
Gemma 3 87
Gemma 4 61
T5 11B 33(极深!)

大多数模型在 100-200 之间。

为什么不是越深越好?

系统层面的原因: - 太深的模型更难并行化(层之间有依赖,不能完全并行) - 太深的模型延迟更高(数据要经过更多层)

效果层面的原因: - 有研究表明,宽和深都能提升效果 - 但太深了之后,训练难度增加,收益递减

💡 实践建议: - 如果追求训练速度和并行效率 → 稍微宽一点 - 如果追求推理速度(参数少效果好)→ 稍微深一点 - 但不要太极端,100-200 之间是安全区


4. 词表大小

问题:词表应该多大?

经验法则

  • 单语言模型:30K-50K
  • 多语言/生产系统:100K-250K

实际模型的数据

单语言: | 模型 | 词表大小 | |------|---------| | 原始 Transformer | 37,000 | | GPT | 40,257 | | GPT-2/3 | 50,257 | | T5 | 32,128 | | LLaMA | 32,000 |

多语言: | 模型 | 词表大小 | |------|---------| | mT5 | 250,000 | | PaLM | 256,000 | | GPT-4 | 100,276 | | Gemma 4 | 262,144 | | DeepSeek | 100,000 | | Qwen 15B | 152,064 |

💡 为什么多语言词表更大? 因为要覆盖多种语言的字符和常见词,需要更多的 token。


5. 正则化:大模型还需要 dropout 吗?

Dropout

Dropout 是一种经典的正则化方法:训练时随机把一些神经元"关掉",防止过拟合。

但大模型预训练时还用 dropout 吗?

模型 Dropout
原始 Transformer 0.1
GPT-2 0.1
T5 0.1
GPT-3 0.1
PaLM 0
LLaMA 0
OPT 0.1
Qwen 14B 0.1

趋势:新模型(LLaMA、PaLM 等)大多不用 dropout 了。

为什么? - 大模型预训练时,数据量远大于参数量,过拟合不是主要问题 - Dropout 会减慢训练速度 - 有更好的正则化方式(比如 weight decay)

Weight Decay(权重衰减)

Weight decay 就是在损失函数里加一个参数的 L2 正则项,让参数不要太大。

大多数模型都用 weight decay,但它的作用不是控制过拟合,而是影响优化动态: - 和学习率调度(比如 cosine schedule)交互 - 影响训练的稳定性和收敛速度

💡 反直觉的事实:大模型预训练时,正则化的主要作用不是防止过拟合,而是改善优化过程。因为数据太多了,根本记不住(单遍训练,不会重复看同样的数据)。


3.7 训练稳定性:为什么损失会突然飙升?

问题:训练不稳定

训练大模型时,有时候会遇到这种情况: - 训练好好的,损失突然飙升 - 然后可能恢复,也可能直接崩了

这就是训练不稳定的问题。

为什么会不稳定? 很多时候和 softmax 有关。


Softmax 为什么是"罪魁祸首"?

Softmax 函数:

1
softmax(x_i) = exp(x_i) / sum(exp(x_j))

问题在哪里? 1. 指数运算:如果 x 很大,exp(x) 会爆炸(溢出) 2. 除法:如果分母很小,结果会很大 3. 梯度:不稳定的前向传播会导致不稳定的梯度

Transformer 里有两个地方有 softmax: 1. 输出层的 softmax:计算下一个 token 的概率分布 2. 注意力里的 softmax:计算注意力权重

这两个地方都可能出问题。


技巧 1:Z-Loss(输出 softmax 稳定性)

做法:在损失函数里加一项——输出 logits 的 L2 范数。

1
总损失 = 交叉熵损失 + λ × mean(logits²)

为什么有用? - 惩罚太大的 logits - 让 softmax 的输入不要太极端 - 防止溢出和不稳定

代表模型:PaLM、Baichuan 2、DCLM、OLMo 2/3

💡 直觉:就像给汽车装一个限速器,不让它开太快,这样就不容易失控。


技巧 2:QK-Norm(注意力 softmax 稳定性)

做法:在 query 和 key 进入 softmax 之前,先做一次 RMSNorm。

1
attention = softmax(RMSNorm(Q) × RMSNorm(K)^T / sqrt(d))

为什么有用? - 控制 Q 和 K 的尺度 - 让注意力分数不要太极端 - 防止注意力 softmax 不稳定

来源:最早在视觉和多模态模型中使用(Dehghani 2023、Idefics、Chameleon),后来被语言模型采纳。

代表模型:DCLM、OLMo 2、Gemma 2、Qwen 3、OLMo 3、Gemma 4

💡 趋势:QK-Norm 越来越流行,新模型很多都加了。


技巧 3:Logit Soft-Capping

做法:用 tanh 把 logits 限制在某个最大值以内。

1
capped_logits = cap_value × tanh(logits / cap_value)

为什么有用? - 防止 logits 爆炸 - 给 logits 设一个上限

代价:可能会影响性能(限制了模型的表达能力),所以要谨慎选择 cap 值。


3.8 架构设计三原则

最后,总结一下设计 Transformer 架构时要考虑的三个维度:

1. 表达性(Expressivity)

能不能学到复杂的模式?

  • 参数越多 → 表达能力越强
  • 注意力头越多 → 能捕捉的模式越多
  • 更深的网络 → 能表示更复杂的函数

2. 稳定性(Stability)

能不能稳定地训练?

  • 归一化 → 让训练更稳
  • 残差连接 → 让梯度更好传
  • 各种稳定性技巧(Z-Loss、QK-Norm)→ 防止训练崩溃

3. 效率(Efficiency)

在硬件上跑得快不快?

  • 计算效率:FLOPs 少不少
  • 内存效率:显存占多少,内存访问多不多
  • 并行效率:能不能很好地利用多 GPU

💡 好的架构 = 三者的平衡

  • 只追求表达性 → 训不动 / 跑太慢
  • 只追求稳定性 → 效果不好
  • 只追求效率 → 效果差 / 训不稳

最好的架构是在这三者之间找到最佳平衡点。


3.9 小白常见问题 Q&A

Q:为什么大家都抄 LLaMA 的架构?

A:因为 LLaMA 的架构是经过验证的"安全选择"。

经过这么多年的研究,大家发现: - Pre-norm + RMSNorm + RoPE + SwiGLU + 无 bias 这个组合效果很好 - 各种小改动(换个激活函数、改一下归一化)带来的提升都不大 - 与其在架构上冒险,不如用验证过的配置,把精力放在其他地方(数据、训练方法等)

所以新模型基本都是"LLaMA-like"的架构,只做一些小改动。

💡 架构创新的边际收益在递减。现在提升模型效果主要靠数据、规模、训练技巧,而不是架构改动。

Q:架构创新还有意义吗?

A:有,但不是在"堆层数"或者"换激活函数"这种层面了。

真正有意义的架构创新是: - 能大幅提升效率的(比如 MoE、线性注意力) - 能解决根本性问题的(比如长上下文、推理速度) - 能开启新能力的(比如多模态、世界模型)

小修小补的架构改动意义不大了。

Q:我做项目应该选什么配置?

A:如果你是做项目,不是做研究,建议直接用"标准配置":

  • 架构:LLaMA 风格(Pre-norm + RMSNorm + RoPE + SwiGLU)
  • FFN 比例:8/3 ≈ 2.67(GLU 变体)
  • Head 比例:1
  • 宽深比:100-150 之间
  • 词表:根据你的语言和数据选(中文 30K-60K 差不多)
  • 正则化:不用 dropout,用 weight decay

这些都是经过大量验证的"安全选择",不会出大问题。

Q:为什么训练会不稳定?怎么解决?

A:训练不稳定的原因很多,常见的有: 1. 学习率太大 → 调小学习率,加 warmup 2. 数据有问题 → 清洗数据 3. 架构不稳定 → 加 QK-Norm、Z-Loss 等技巧 4. 梯度爆炸 → 梯度裁剪(gradient clipping)

如果你遇到训练不稳定的问题,建议从易到难排查: 1. 先检查数据(有没有脏数据、异常值) 2. 再调学习率(调小一点,warmup 长一点) 3. 再加稳定性技巧(QK-Norm、Z-Loss)


3.10 第三讲学习路线图

你应该掌握到什么程度?

必须掌握: - ✅ 现代 Transformer 的"标准配置"是什么 - ✅ Pre-norm 为什么比 Post-norm 好 - ✅ RMSNorm 和 LayerNorm 的区别 - ✅ 注意力和 FFN 分别做什么 - ✅ RoPE 的核心思想(旋转 → 相对位置) - ✅ GQA/MQA 是干什么的(减少 KV Cache) - ✅ 主要超参数的"经验取值"

理解即可: - 📖 GLU 的具体数学形式 - 📖 QK-Norm、Z-Loss 的细节 - 📖 滑动窗口注意力的具体实现

怎么配合作业 1 学习?

作业 1 会让你实现一个完整的 Transformer,建议: 1. 先理解整体架构(每一层有什么) 2. 再逐个组件实现(Norm、Attention、FFN……) 3. 最后拼起来,跑测试

💡 提示:实现 Transformer 不难,但要注意细节(比如 RoPE 的实现、注意力 mask、残差连接的位置)。对照测试用例调试是最快的学习方式。


📋 附录:关键公式速查表(带解释)

Tokenization

公式 解释
压缩比 = 字节数 / token 数 平均一个 token 代表多少字节

资源核算

公式 解释
矩阵乘法 FLOPs = 2 × M × N × K M×K 矩阵乘 K×N 矩阵
训练 1 step FLOPs ≈ 6 × batch × params 前向 2 倍 + 反向 4 倍
算术强度 = FLOPs / 字节访问量 每读 1 字节做多少次计算
加速器强度 = 峰值 FLOP/s / 带宽 GPU 本身的计算/搬运比
MFU = 实际 FLOP/s / 峰值 FLOP/s GPU 利用率
Adam 每参数内存 = 12 字节 2(参数)+2(梯度)+8(优化器状态)

架构

组件 现代标准
归一化位置 Pre-norm
归一化类型 RMSNorm
位置编码 RoPE
FFN 激活 SwiGLU
FFN 比例 ~2.67× d_model(GLU)/ 4× d_model(普通)
偏置项 无
注意力 GQA(推理友好)

🎯 三讲核心思想总结

学完前三讲,你应该建立这样的思维框架:

  1. 效率是核心:大模型的一切都围绕效率展开
    • Tokenization 是效率问题(压缩比)
    • 架构设计是效率问题(计算 / 内存 / 稳定性的平衡)
    • 系统优化是效率问题(怎么充分利用硬件)
  2. 权衡无处不在:没有"最好",只有"最合适"
    • 词表大小:压缩比 vs 稀疏性
    • 宽深比:表达性 vs 并行性
    • 精度:速度 vs 稳定性
    • 注意力:效果 vs 速度
  3. 经验法则很有用:站在巨人的肩膀上
    • 很多超参数有"标准取值",不要从零开始瞎试
    • 先从标准配置开始,再根据需要调整

文档说明:本辅助学习文档基于 Stanford CS336 Spring 2026 前三讲内容整理,面向零基础学习者。如果有任何概念看不懂,不要灰心——大模型的知识体系确实很庞大,慢慢来,多写代码,多调试,慢慢就懂了。

记住:看懂了不算会,动手写代码、跑实验、踩坑、调试,这才是真正的学习。


CS336 学习笔记 01:分词、资源核算与 Transformer
https://cdro.tech/notes/CS/cs336-01-l1-l3-tokenization-resource-accounting-transformer/
作者
k9Q6CK42
发布于
2026年9月28日
更新于
2026年9月28日
许可协议