CS336 学习笔记 01:分词、资源核算与 Transformer
本文最后更新于 2026年9月28日 晚上
CS336 入门辅助学习文档 - Transformer 架构、分词、资源核算
📖 写在前面:你现在在哪里?
这门课在讲什么?
CS336 的全称是 "Language Modeling from Scratch"(从零构建语言模型)。
用一句话说:这门课教你怎么从头到尾自己造一个大语言模型(类似 GPT)。
不是调 API,不是微调别人的模型,而是从最底层的分词器开始,到架构设计、到训练系统、到数据处理、到对齐方法——每一层都自己实现。
你需要什么基础?
说实话,这门课有一定难度。但好消息是: - ✅ 你不需要懂深度学习的全部理论 - ✅ 你不需要有训练大模型的经验 - ✅ 你只需要会基本的 Python 编程 - ✅ 有一点线性代数和概率论的基础会更好(没有也没关系,我会用大白话解释)
怎么读这份文档?
- 每一节都从"为什么"开始:先理解为什么需要这个东西,再学它是什么
- 遇到公式别怕:我会用文字和例子解释每一个符号的含义
- 配合原始讲义看:这份文档是辅助,原始讲义(lecture_01.py 等)是权威来源
- 动手最重要:看懂了不算会,跟着作业写一遍代码才算真会
📚 术语对照表
| 英文术语 | 中文翻译 | 一句话解释 |
|---|---|---|
| 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("跑" | "今天天气真") = 很低的概率
关键问题来了
语言模型要处理的是"词",但电脑根本不认识文字啊!电脑只认识 0 和 1。
怎么把文字变成电脑能处理的数字呢? → 这就是 Tokenization(分词)要解决的问题。
1.1 为什么需要 Tokenization?
核心问题:文字 → 数字
电脑只认识数字,所以我们必须把文字转换成数字序列。
比如这句话: 1
"我爱学习"
我们需要把它变成类似这样的东西: 1
[123, 456, 789, 101]
每个数字代表一个"基本单位",这个基本单位就叫 token(词元)。
那怎么分呢?
这就是关键问题了:我们应该以什么为单位来切分文字?
- 按字?
- 按词?
- 按字节?
- 按某种更聪明的方式?
不同的分法各有优缺点,接下来我们一个一个看。
1.2 四种分词方案:逐个拆解
方案一:字符级分词(Character-level)
做法:每个字符就是一个 token。
1 | |
优点: - 词表小(中文常用字几千个,英文 26 个字母) - 不会遇到"不认识的字"(所有字都在词表里)
缺点: - 序列太长了!一句话有多少字就有多少个 token - 后面我们会学到,注意力机制的计算量是序列长度的平方 → 序列长了,计算量爆炸 - 字的语义太单薄,模型学起来更难
类比:就像让你用"笔画"来读书——每个字都拆成笔画,你能读,但特别费劲。
方案二:字节级分词(Byte-level)
等等,什么是字节?
你可能听说过 UTF-8、Unicode 这些词。简单说: - 世界上所有文字(中文、英文、 emoji……)都有一个统一的编号,叫 Unicode - 但这些编号在电脑里存储时,需要用某种"编码格式"转成字节(0-255 的数字) - UTF-8 就是最常用的编码格式 - 一个英文字母通常占 1 个字节 - 一个中文字通常占 3 个字节
字节级分词:直接以字节为单位,每个字节是一个 token。
1 | |
优点: - 词表超级小,固定 256 个(因为一个字节就是 0-255) - 绝对不会遇到"不认识的字符"(所有东西都是字节组成的)
缺点: - 序列更长了!比字符级还长 - 计算量更大 - 语义更难学(字节本身没有意义)
类比:就像让你用"二进制"来读书——全是 0101,你根本看不懂。
方案三:词级分词(Word-level)
做法:按"词"来分,每个词是一个 token。
1 | |
优点: - 压缩比高(序列短) - 每个 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 | |
第二步:第一次合并
统计所有相邻 pair 的出现次数:
- l-o:出现 2 次
- o-w:出现 2 次
- w-l:出现 1 次
- w-e:出现 1 次
- e-r:出现 1 次
最常见的 pair 是 l-o 和 o-w(都是 2 次),我们随便选一个,比如 l-o
创建新 token:
lo(编号 6)把序列中所有的 l-o 替换成 lo:
1
2原序列:l o w l o w e r
替换后:lo w lo w e r词表更新:{l, o, w, e, r, lo} → 6 个 token
第三步:第二次合并
统计新序列中的相邻 pair:
- lo-w:出现 2 次
- w-lo:出现 1 次
- w-e:出现 1 次
- e-r:出现 1 次
最常见的是 lo-w(2 次)
创建新 token:
low(编号 7)替换:
1
2原序列:lo w lo w e r
替换后:low low e r词表更新:{l, o, w, e, r, lo, low} → 7 个 token
第四步:继续合并……
可以一直合并下去,直到达到你想要的词表大小,或者没有可以合并的了。
编码(Encode):怎么用训练好的 BPE 分词?
假设我们训练好了上面的 BPE,现在要编码一个新词:"lower"
- 先把词拆成单个字符:
l o w e r - 按训练时的合并顺序,依次应用每一次合并:
- 第一次合并(l-o → lo):
lo w e r - 第二次合并(lo-w → low):
low e r
- 第一次合并(l-o → lo):
- 最终结果:
[low, e, r]→ 3 个 token
解码(Decode):怎么把 token 还原成文字?
解码就更简单了:
- 每个 token 对应一段字节序列
- 把所有字节序列拼起来
- 用 UTF-8 解码成文字
比如: 1
[low, e, r] → "low" + "e" + "r" → "lower"
💡 关键点:解码是直接查表拼接,不需要算法。编码才需要按顺序应用合并规则。
1.4 压缩比:为什么分词很重要?
什么是压缩比?
1 | |
简单说:平均一个 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 上有两个最重要的东西:
- 计算单元(ALU / Tensor Core):负责做加减乘除运算
- 衡量指标:FLOP/s(每秒能做多少次浮点运算)
- 类比:工厂里的工人数量 × 每个工人的速度
- 显存(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 | 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 | |
问题来了:-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
2y = 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)
例子 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 | |
💡 超级重要的经验公式:训练一步 ≈ 6 倍的(批量大小 × 参数数量)
速记口诀:前向 2 倍,反向 4 倍,加起来 6 倍
MFU:模型浮点利用率
MFU = Model FLOPs Utilization = 模型浮点利用率
1 | |
简单说:GPU 的计算能力有百分之多少真的用上了?
- MFU = 100% → GPU 全程满负荷运转(理想状态,几乎达不到)
- MFU = 50% → 一半时间在计算,一半时间在等数据
- MFU ≥ 50% 通常已经算很好了
为什么达不到 100%? - 内存带宽不够(数据搬不过来) - 通信开销(多 GPU 之间传数据) - 一些小操作(激活函数、归一化)是内存受限的
2.5 算术强度:这才是性能的核心
这是第二讲最重要、最核心的概念,请认真读。
用"工厂流水线"来理解
想象一个工厂: - 仓库(内存):存放原材料和成品 - 车间(计算单元):加工原材料 - 传送带(内存带宽):在仓库和车间之间搬运东西
生产一个产品的过程: 1. 从仓库运原材料到车间(数据搬运) 2. 车间加工(计算) 3. 把成品运回仓库(数据搬运)
问题:工厂的瓶颈在哪里? - 如果传送带很慢,车间经常在等原材料 → 瓶颈在搬运(内存受限) - 如果车间加工很慢,传送带闲着没事干 → 瓶颈在加工(计算受限)
两个关键指标
1. 加速器强度(Accelerator Intensity)
1 | |
这是 GPU 本身的属性,代表:每搬运 1 字节数据,GPU 能做多少次计算。
- 加速器强度高 → GPU 计算能力很强,相对来说搬运比较慢
- 加速器强度低 → GPU 计算能力一般,搬运相对还可以
2. 算术强度(Arithmetic Intensity)
1 | |
这是操作本身的属性,代表:每读 1 字节数据,需要做多少次计算。
- 算术强度高 → 计算多,数据少(比如大矩阵乘法)
- 算术强度低 → 计算少,数据多(比如逐元素操作)
怎么判断瓶颈?
很简单,比较两个值:
| 关系 | 瓶颈 | 含义 |
|---|---|---|
| 算术强度 < 加速器强度 | 内存受限 | 数据搬不过来,计算单元在等 |
| 算术强度 > 加速器强度 | 计算受限 | 计算忙不过来,数据搬运在等 |
💡 类比: - 算术强度 = 每个产品需要加工的时间 - 加速器强度 = 传送带的搬运速度 / 车间的加工速度 - 如果产品加工很快(算术强度低),车间经常等原材料 → 搬运是瓶颈 - 如果产品加工很慢(算术强度高),原材料堆在车间门口 → 加工是瓶颈
Roofline 模型:一张图看懂
把上面的关系画成图,就是 Roofline 模型:
1 | |
怎么读这张图? - 左边(斜坡):算术强度低 → 内存受限 → 性能随算术强度线性增长 - 右边(平顶):算术强度高 → 计算受限 → 性能达到峰值,不再增长 - 拐点:刚好从内存受限变成计算受限的位置,位置 = 加速器强度
常见操作的算术强度
以 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 | |
效果: - 激活值内存:原来的 1/N(因为每次只存一个 micro batch 的激活) - 总计算量:不变(还是算同样多的数据) - 训练效果:和大 batch 基本一样
💡 梯度累积是用时间换空间:花更多时间(多步前向反向),省内存。
方法二:激活检查点(Activation Checkpointing)
问题:反向传播需要用到前向传播的所有中间激活值 → 这些都存在显存里 → 占很多内存。
方案:前向传播时,不存所有层的激活值,只存一部分(检查点)。反向传播时,遇到缺失的激活值,就从最近的检查点重新算一遍。
1 | |
权衡:用计算换内存。
- 极端情况 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 | |
就是把输入直接加到输出上,形成一条"捷径"。
为什么有用? - 梯度可以沿着捷径直接传回去,不容易消失 - 层只需要学"残差"(输入和输出的差),更容易学
💡 类比:就像考试的时候,你先写一个基础答案(输入),然后在旁边补充修改(层的输出)。即使补充修改错了,基础答案还在,不会完全跑偏。
3.1 Transformer 整体结构:先看全貌
一句话总结
Transformer 就是一堆 Transformer Block 叠起来。
每个 Transformer Block 里有两个主要部分: 1. 自注意力层(Self-Attention):让每个词"看"其他词,决定关注谁 2. 前馈网络层(FFN):对每个词单独做非线性变换
再加上: - 残差连接:每一层都有 - 归一化(Norm):让训练更稳定
一层 Transformer Block 长什么样?
1 | |
💡 注意:上面画的是 Pre-norm(归一化在子层前面),这是现代 Transformer 的标准做法。原始 Transformer 是 Post-norm(归一化在残差后面)。
数据是怎么流动的?
假设输入是一句话,有 N 个 token,每个 token 是一个 D 维的向量:
- 注意力层:每个 token 都和所有 token
"交流"一下,更新自己的表示
- 输入形状:(N, D)
- 输出形状:(N, D)
- 特点:token 之间有信息交互
- FFN 层:每个 token 自己做一次非线性变换
- 输入形状:(N, D)
- 输出形状:(N, D)
- 特点:token 之间独立,各自变换
💡 类比:注意力层像"小组讨论"——每个人都听听别人说什么,然后更新自己的想法;FFN 层像"独立思考"——每个人自己消化一下刚才讨论的内容。
3.2 归一化(Norm):让训练稳定的秘诀
为什么需要归一化?
训练深度神经网络时,每一层的输入分布可能会不断变化(这个现象叫 "Internal Covariate Shift")。这会导致: - 训练不稳定 - 学习率不能太大 - 深层网络很难训
归一化就是把每一层的输入"拉"到一个合适的范围,让训练更稳定。
💡 类比:就像做饭时控制火候——火太大容易糊,火太小熟得慢。归一化就是把"火"调到合适的大小。
Pre-norm vs Post-norm:放哪里很重要
归一化放在哪里?有两种选择:
Post-norm(原始 Transformer)
1 | |
归一化在残差连接之后,在残差流上。
Pre-norm(现代标准)
1 | |
归一化在子层内部,不在残差流上。
为什么 Pre-norm 更好?
- 梯度衰减更少:梯度可以沿着残差连接直接传回去,不会被归一化"削弱"
- 梯度尖峰更少:训练更稳定,不容易出现突然的梯度爆炸
- 不需要 warmup:Post-norm 通常需要学习率 warmup(慢慢提高学习率),Pre-norm 不用
- 大网络更稳定:模型越大,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 | |
做了什么: 1. 减去均值(让数据的均值为 0) 2. 除以标准差(让数据的方差为 1) 3. 乘以可学习的缩放因子 γ 4. 加上可学习的偏置 β
RMSNorm(均方根归一化)
1 | |
做了什么: 1. 除以均方根(只归一化"大小",不减均值) 2. 乘以可学习的缩放因子 γ 3. 没有偏置 β
区别:RMSNorm 比 LayerNorm 简单: - 不减均值 - 不加偏置 - 少了一些计算
为什么 RMSNorm 更好?
- 更快:
- 更少的操作(不用算均值)
- 更少的参数(没有 bias)
- 更少的内存访问(数据搬运少)
- 效果差不多:
- 实践中 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 | |
- 正数不变,负数变 0
- 最简单、最经典的激活函数
- 代表模型:原始 Transformer、T5、OPT
GELU(Gaussian Error Linear Unit)
1 | |
- 比 ReLU 平滑,有一点"渐变"
- 代表模型:GPT-1/2/3、BLOOM
SwiGLU / GeGLU(门控线性单元)
这个稍微复杂一点,我们单独讲。
门控线性单元(GLU 变体)
普通 FFN 长什么样?
1 | |
就是两个线性层中间夹一个激活函数。
形状变化: 1
d_model → d_ff → d_model
门控 FFN 长什么样?
1 | |
多了什么? - 多了一个线性变换 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/3? 因为多了一个矩阵,参数多了 50%。为了总参数差不多,d_ff 缩小到 2/3。
GLU 有效吗?
答案:有效,而且效果挺一致的。
多篇论文都表明 GLU 变体比普通 FFN 好: - Shazeer 2020 的论文系统比较了各种激活函数 - 后续很多工作都验证了这一点
虽然不是必须的(GPT-3 没用 GLU 也很好),但 2023 年后几乎所有新模型都用 SwiGLU 了。
💡 为什么 GLU 有效?
直觉上,门控机制让网络有了"选择"的能力——它可以决定哪些信息保留、哪些信息丢弃。这种灵活性可能是性能提升的原因。
但说实话,我们还没有完全理解为什么 GLU 这么有效。很多深度学习的发现都是"先有效,后理解"。
串行 vs 并行层
串行(标准做法)
1 | |
先算注意力,再算 FFN,一步一步来。
并行
1 | |
注意力和 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)
怎么实现?用旋转!
关键数学性质:内积在旋转下是不变的。
什么意思?如果你把两个向量都旋转同样的角度,它们的内积不变。
但是……如果两个向量旋转不同的角度呢?它们的内积就会变化,而且变化量只取决于角度差。
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 | |
就是说,所有头的维度加起来差不多等于模型维度。
实际模型的数据
| 模型 | 比例 |
|---|---|
| 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 |
|---|---|
| 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 | |
为什么有用? - 惩罚太大的 logits - 让 softmax 的输入不要太极端 - 防止溢出和不稳定
代表模型:PaLM、Baichuan 2、DCLM、OLMo 2/3
💡 直觉:就像给汽车装一个限速器,不让它开太快,这样就不容易失控。
技巧 2:QK-Norm(注意力 softmax 稳定性)
做法:在 query 和 key 进入 softmax 之前,先做一次 RMSNorm。
1 | |
为什么有用? - 控制 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 | |
为什么有用? - 防止 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(推理友好) |
🎯 三讲核心思想总结
学完前三讲,你应该建立这样的思维框架:
- 效率是核心:大模型的一切都围绕效率展开
- Tokenization 是效率问题(压缩比)
- 架构设计是效率问题(计算 / 内存 / 稳定性的平衡)
- 系统优化是效率问题(怎么充分利用硬件)
- 权衡无处不在:没有"最好",只有"最合适"
- 词表大小:压缩比 vs 稀疏性
- 宽深比:表达性 vs 并行性
- 精度:速度 vs 稳定性
- 注意力:效果 vs 速度
- 经验法则很有用:站在巨人的肩膀上
- 很多超参数有"标准取值",不要从零开始瞎试
- 先从标准配置开始,再根据需要调整
文档说明:本辅助学习文档基于 Stanford CS336 Spring 2026 前三讲内容整理,面向零基础学习者。如果有任何概念看不懂,不要灰心——大模型的知识体系确实很庞大,慢慢来,多写代码,多调试,慢慢就懂了。
记住:看懂了不算会,动手写代码、跑实验、踩坑、调试,这才是真正的学习。