CS336 Notes
[toc]
Lecture 2 笔记:从张量到训练循环
1. 分词技术 (Tokenization)
1.1 分词的背景与意义
自然语言模型的输入是**整数序列 (tokens)**,因此必须先将原始文本转为 tokens。
Tokenizer 的职责是:
- **编码 (encode)**:string → tokens
- **解码 (decode)**:tokens → string
分词器的选择直接影响模型的效率、表达能力和泛化能力。
在课程中,tokenization 被称为一种 必要的“恶”:目前主流模型必须使用分词,但理想情况下我们希望模型能直接处理原始字节序列。
1.2 分词策略对比
| 策略 | 词汇量大小 | 压缩率 | OOV 处理 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 字符分词 (Character) | 巨大 (~15 万 Unicode 字符) | 低 (~1.5) | 不存在 OOV | 能表示任何文本,实现简单 | 词汇表过大,序列过长,效率低 |
| 字节分词 (Byte) | 小且固定 (256) | 极低 (=1) | 不存在 OOV | 词表小,覆盖面全 | 序列最长,注意力计算极不友好 |
| 词分词 (Word) | 巨大且不固定 | 高 | 需 UNK token | 序列短,符合语言直觉 | 词表爆炸,无法优雅处理新词 |
| BPE (Byte Pair Encoding) | 可控 (可自定义) | 中等 | 不存在 OOV | 在词汇量与序列长度间平衡 | 贪心算法,非全局最优,训练复杂 |
结论
- 字符/字节分词:覆盖面广,但序列太长。
- 词分词:序列短,但 OOV 问题严重。
- BPE:折中方案,成为主流(GPT-2/3/4 使用)。
1.3 分词策略详解
1.3.1 字符分词 (Character Tokenizer)
思想:每个 Unicode 字符 → code point (整数)。
示例:
assert ord("a") == 97
assert ord("🌍") == 127757
assert chr(97) == "a"
assert chr(127757) == "🌍"实现:
class CharacterTokenizer(Tokenizer):
def encode(self, string: str) -> list[int]:
return list(map(ord, string))
def decode(self, indices: list[int]) -> str:
return "".join(map(chr, indices))问题:
- vocab 过大 (~150K)
- 序列过长,稀有字符浪费容量
1.3.2 字节分词 (Byte Tokenizer)
思想:UTF-8 编码 → 字节序列 (0-255)。
示例:
bytes("a", "utf-8") == b"a"
bytes("🌍", "utf-8") == b"\xf0\x9f\x8c\x8d"实现:
class ByteTokenizer(Tokenizer):
def encode(self, string: str) -> list[int]:
return list(map(int, string.encode("utf-8")))
def decode(self, indices: list[int]) -> str:
return bytes(indices).decode("utf-8")优点:
- 词表固定 256
- 无 OOV
缺点:
- 压缩率始终为 1,序列过长
- 对 Transformer 的注意力计算 (O(n²)) 极为低效
1.3.3 词分词 (Word Tokenizer)
思想:用正则表达式拆分单词。
示例:
import regex
string = "I'll say supercalifragilisticexpialidocious!"
segments = regex.findall(r"\w+|.", string)优点:
- 序列短,语义自然
缺点:
- vocab 爆炸,新词需 UNK
- OOV 导致困惑度计算困难
1.3.4 BPE (Byte Pair Encoding)
历史:
- Gage (1994):用于数据压缩
- Sennrich (2015):引入机器翻译
- Radford (2019):GPT-2 采用 BPE
核心思想:
- 从字节序列开始
- 统计相邻 token 对频率
- 将最高频对合并为新 token
- 迭代,直到词表大小达到预设值
训练算法 (train_bpe)
def train_bpe(string: str, num_merges: int) -> BPETokenizerParams:
indices = list(map(int, string.encode("utf-8")))
merges, vocab = {}, {x: bytes([x]) for x in range(256)}
for i in range(num_merges):
counts = defaultdict(int)
for a, b in zip(indices, indices[1:]):
counts[(a, b)] += 1
pair = max(counts, key=counts.get)
new_index = 256 + i
merges[pair] = new_index
vocab[new_index] = vocab[pair[0]] + vocab[pair[1]]
indices = merge(indices, pair, new_index)
return BPETokenizerParams(vocab, merges)编码 (encode)
- 将字符串转为字节
- 按 merges 规则合并
解码 (decode)
- 查 vocab → 拼接字节 → UTF-8 解码
优势:
- 高频片段 → 单 token,提高压缩率
- 低频片段 → 多 token,保证泛化
- 无 OOV
1.4 压缩率 (Compression Ratio)
定义:
$\text{compression ratio} = \frac{\text{num_bytes}}{\text{num_tokens}}$
- 越高说明 token 越“信息密集”。
- 比较:
- 字符分词:~1.5
- 字节分词:=1
- 词分词:高,但 OOV 问题严重
- BPE:折中,通常 1.5–3 左右
1.5 总结
- Tokenizer = encode + decode
- 字符/字节分词:覆盖全面,但序列过长,效率低
- 词分词:序列短,但 OOV 问题严重
- BPE:折中方案,兼顾 vocab 控制与序列长度,成为主流
- 趋势:
- 探索 tokenizer-free 模型(直接从字节输入),但目前未能在大规模上取代 BPE
Lecture 2 笔记:从张量到训练循环
1 引言:从资源到模型——构建大模型的基石
- 本讲的目标:解析训练一个现代深度学习模型所需的所有核心技术原语。
- 方法:自下而上 (bottom-up),从 张量 (Tensors) → 模型 (Models) → 优化器 (Optimizers) → **训练循环 (Training Loop)**。
- 核心理念:效率 (Efficiency)
- **内存 (Memory)**:以 GB 为单位,决定模型与 batch 的大小。
- **计算 (Compute)**:以 FLOPs 为单位,决定训练时间与成本。
- 知识框架:
- Mechanics:PyTorch 原语,直观实现。
- Mindset:资源核算,效率优先。
- Intuitions:理解大体趋势与代价,而非细节调优。
2 动机:餐巾纸数学 (Napkin Math)
通过粗略估算快速感受大模型训练的规模:
问题一:70B 参数模型,15T tokens,1024 × H100 需要多久?
总计算量:
Total FLOPs=6×N×D\text{Total FLOPs} = 6 \times N \times D
- $N = 70 \times 10^9$ (参数)
- $D = 15 \times 10^{12}$ (tokens)
- 系数 6 来源:前向传播 $2N$ + 反向传播 $4N$ = $6N$ FLOPs。
- 结果:$6.3 \times 10^{24}$ FLOPs。
每日计算量:
- 单卡 H100 理论峰值:$1979 \times 10^{12}$/s
- 假设 MFU (利用率) = 0.5
- 1024 卡每天:约 $4.35 \times 10^{22}$ FLOPs
所需天数:
Days=6.3×10244.35×1022≈145\text{Days} = \frac{6.3 \times 10^{24}}{4.35 \times 10^{22}} \approx 145
结论:训练 GPT-3 规模模型即便在千卡 H100 上也需 ~5 个月。
问题二:8 × H100,AdamW,能训练的最大模型多大?
单卡显存:80 GB
朴素 float32 AdamW 内存开销:
- 参数:4 B
- 梯度:4 B
- 优化器状态 (m, v):8 B
- 总:16 B/参数
8 卡总内存:$80 \times 10^9 \times 8 = 640$ GB
可训练参数量:
Num Params=640×10916≈40×109\text{Num Params} = \frac{640 \times 10^9}{16} \approx 40 \times 10^9
结论:约 40B 参数模型(未计激活内存)。
3 张量
3.1 张量基础 (Tensors Basics)
张量是深度学习中存储一切的基本单元:参数、梯度、优化器状态、数据、激活。
创建张量:
x = torch.tensor([[1., 2, 3], [4, 5, 6]])
x = torch.zeros(4, 8)
x = torch.ones(4, 8)
x = torch.randn(4, 8)
x = torch.empty(4, 8) # 未初始化
nn.init.trunc_normal_(x, mean=0, std=1, a=-2, b=2)
用途:先分配内存,后用自定义逻辑初始化。
3.2 内存核算 (Memory Accounting)
几乎所有内容都存为浮点数。关键:值的数量 × 每个值的字节数。
3.2.1 float32 (单精度)
- 结构:1 符号位 + 8 指数位 + 23 尾数位
- 大范围,高精度,默认类型
- 内存示例:
torch.zeros(4, 8)→ $4 \times 8 \times 4$ B = 128 B- GPT-3 前馈层矩阵 $12288 \times 12288$ → 2.3 GB
3.2.2 float16 (半精度)
- 结构:1 符号位 + 5 指数位 + 10 尾数位
- 内存减半 (2B/值)
- 缺陷:动态范围窄,小数易下溢 (如 $1e-8 \to 0$)
- 风险:训练不稳定
3.2.3 bfloat16 (脑浮点)
- Google Brain 2018 提出
- 结构:1 符号位 + 8 指数位 + 7 尾数位
- 动态范围 = float32,避免下溢
- 精度低于 float16,但足够用于深度学习
- 常用于计算,而参数/优化器仍存 float32
3.2.4 FP8 (8位浮点)
- NVIDIA H100 支持 (2022)
- 两种:E4M3、E5M2
- 内存更小,但需配合 Transformer Engine 软件栈
- 依赖自动缩放/转换策略
3.3 浮点格式对比
| 特性 | float32 | float16 | bfloat16 | FP8 |
|---|---|---|---|---|
| 总位数 | 32 | 16 | 16 | 8 |
| 指数位 | 8 | 5 | 8 | 4/5 |
| 尾数位 | 23 | 10 | 7 | 2/3 |
| 动态范围 | 极大 | 有限 | 与 fp32 相同 | 较窄 |
| 精度 | 高 | 中 | 低 | 极低 |
| 内存/计算开销 | 大 | 中 | 中 | 极小 |
| 训练稳定性 | 稳定 | 容易下溢 | 稳定 | 需谨慎 |
要点:
- float32:稳定可靠,内存开销大
- float16:节省内存,但风险高
- bfloat16:折中,常用
- FP8:极致效率,需硬件/软件支持
3.4 现代解决方案:混合精度训练 (Mixed Precision Training)
- 思路:结合高精度与低精度优势
- 参数/优化器:float32
- 前向/部分计算:bfloat16 / fp16
- 部分关键模块(如注意力):保留 float32
- 工具:NVIDIA Transformer Engine 提供自动缩放和类型转换
结论:混合精度是当前主流,兼顾速度、内存与稳定性。
4 张量存储与视图 (Tensor Storage & Views)
PyTorch 张量本质:指针 + 元数据
- Shape:形状
- Stride:跨距 (定位逻辑索引到内存)
- Storage Offset:起始偏移量
示例:4×4 矩阵
x = torch.tensor([
[0., 1, 2, 3],
[4, 5, 6, 7],
[8, 9, 10, 11],
[12, 13, 14, 15],
])
assert x.stride(0) == 4 # 行方向跨距
assert x.stride(1) == 1 # 列方向跨距定位元素公式:
结论:张量是存储的“视图”,而不是独立的数据块。
5 零成本操作:视图 vs 拷贝
- **视图 (View)**:只改变元数据,不复制底层存储
x[0, :]、x[:, 1].view()、.transpose()- 修改原张量会同步影响视图
- **拷贝 (Copy)**:创建新存储,占用额外内存
- 非连续张量必须
.contiguous()后才能.view()
- 非连续张量必须
要点:性能优化中尽量使用视图,避免无意识拷贝。
6 张量操作分类
6.1 逐元素操作 (Element-wise)
对每个元素独立运算
示例:
x = torch.tensor([1, 4, 9])
x.pow(2) # 平方
x.sqrt() # 开方
x + x
x * 2复杂示例:因果注意力 mask
x = torch.ones(3, 3).triu()
复杂度:$O(mn)$
6.2 矩阵乘法 (Matrix Multiplication)
核心运算:“深度学习的面包黄油”
示例:
x = torch.ones(16, 32)
w = torch.ones(32, 2)
y = x @ w批处理:输入 $(B, S, D_{in})$,权重 $(D_{in}, D_{out})$
- 输出 $(B, S, D_{out})$
复杂度:$O(BDK)$,FLOPs = $2BDK$
6.3 高级张量操作:Einops 库
动机:PyTorch 默认索引可读性差 (
-1, -2容易出错)核心函数:
einsum:广义矩阵乘法
z = einsum(x, y, "batch seq1 hidden, batch seq2 hidden -> batch seq1 seq2")
reduce:聚合降维
y = reduce(x, "batch seq hidden -> batch seq", "mean")
rearrange:拆分与合并维度
x = rearrange(x, "b s (h d) -> b s h d", h=num_heads)
结论:einops 提升可读性与健壮性,适合复杂模型(如多头注意力)。
7 计算核算 (Compute Accounting)
7.1 FLOPs 与 FLOP/s
- FLOPs:总运算量 (e.g. GPT-3 训练 $~3.1 \times 10^{23}$)
- FLOP/s:硬件性能 (e.g. H100 $~ 10^{15}$/s)
- 注意区分:小写 s (总量) vs 大写 S (速率)
7.2 矩阵乘法 FLOPs
- 公式:$2 \times m \times n \times p$
- 前向传播 FLOPs ≈ $2 \times$ (tokens × 参数)
- 反向传播 FLOPs ≈ $4 \times$ (tokens × 参数)
- 总 FLOPs = 前向 + 反向 = $6$ × (tokens × 参数)
7.3 硬件利用率 (MFU)
- 定义:
- 指标:>0.5 已属优秀
- 瓶颈可能来源:I/O、通信、内存访问、非优化代码
- 提升 MFU = 更低的成本 + 更快的训练
8 梯度与反向传播 (Gradients & Backprop)
8.1 自动微分 (Autograd)
- 步骤:
requires_grad=True- 前向传播 → loss
- 调用
.backward() - 梯度存于
.grad
8.2 梯度 FLOPs
- 两层线性模型:
- $h1 = x @ w1$,$h2 = h1 @ w2$
- loss = $h2^2$.mean()
- 前向 FLOPs:$2BD(D+K)$
- 反向 FLOPs:$4BD(D+K)$
- 总 FLOPs = 前向 + 反向 = $6B \times$ 参数量
- 结论:反向传播的计算量 ≈ 前向的两倍
小结
- 张量是指针 + 元数据,视图操作零成本,拷贝需谨慎。
- 逐元素运算 $O(n)$,矩阵乘法 $O(n^3)$,后者主导计算成本。
- Einops 提供声明式接口,简化复杂张量操作。
- FLOPs 核算:前向 2N,反向 4N,总 6N。
- 硬件性能通过 MFU 衡量,>0.5 已属良好。
- 关键认知:效率优化的根本在于理解并量化内存与计算代价。
9 nn.Parameter 与初始化
nn.Parameter:
torch.Tensor的子类,被赋值到nn.Module属性时会自动注册到模型参数列表。优化器会自动追踪这些参数。初始化问题:若输入维度过大,随机初始化可能导致输出方差随输入维度增长,引发梯度爆炸。
解决方案:Xavier 初始化(Glorot 初始化)。通过按输入维度平方根缩放,保证输出方差稳定。
self.weight = nn.Parameter(torch.randn(input_dim, output_dim) / np.sqrt(input_dim))
改进:可进一步使用截断正态分布,避免极端值。
9.1 自定义模型:Linear 与 Cruncher
Linear 层:简单的线性变换,继承自
nn.Module。class Linear(nn.Module):
def __init__(self, input_dim, output_dim):
super().__init__()
self.weight = nn.Parameter(torch.randn(input_dim, output_dim) / np.sqrt(input_dim))
def forward(self, x):
return x @ self.weightCruncher 模型:由多层
Linear堆叠构成的深度线性网络。class Cruncher(nn.Module):
def __init__(self, dim, num_layers):
super().__init__()
self.layers = nn.ModuleList([Linear(dim, dim) for _ in range(num_layers)])
self.final = Linear(dim, 1)
def forward(self, x):
for layer in self.layers:
x = layer(x)
return self.final(x).squeeze(-1)要点:使用
nn.ModuleList而不是 Python list,确保子模块参数被追踪到。
9.2 随机性与可复现性
随机源:参数初始化、数据顺序、Dropout 等。
最佳实践:固定所有随机种子,保证实验可复现性。
import torch, numpy as np, random
seed = 0
torch.manual_seed(seed)
np.random.seed(seed)
random.seed(seed)
9.3 数据加载与管道
- 数据表示:语言模型的数据通常是 tokenizer 输出的整数序列,可序列化为
numpy数组。 - 大规模数据集:TB 级语料无法一次性加载,使用
np.memmap进行懒加载。 - GPU 数据传输优化:
- 使用
pin_memory()固定内存。 - 在
.to(device, non_blocking=True)时实现异步数据传输。
- 使用
9.5 优化器原理与实现
SGD:参数更新公式
p -= lr * grad。Momentum:在梯度更新中引入指数加权平均,抑制震荡。
AdaGrad:为每个参数维护历史梯度平方和,自适应调整学习率。
p -= lr * grad / torch.sqrt(g2 + 1e-5)
RMSProp:对梯度平方使用指数加权平均。
Adam:结合 Momentum 与 RMSProp,成为主流优化器。
AdamW:解耦权重衰减,提升泛化性能。
9.6 内存核算(Parameters, Activations, Gradients, Optimizer States)
参数:$D^2 \times L + D$
激活值:$B \times D \times L$
梯度:与参数量相同。
优化器状态:以 AdaGrad 为例,需要额外存储平方梯度副本。
总内存:
(假设 float32,每个值 4 字节)
9.7 训练循环与最佳实践
- 训练循环核心步骤:
- 获取 batch 数据
- 前向传播计算 loss
- 反向传播计算梯度
- 优化器更新参数
- 清空梯度,进入下一轮
- Checkpointing:定期保存模型与优化器状态,防止长时间训练崩溃后损失全部进度。
- **混合精度训练 (AMP)**:
- 参数与优化器状态:float32
- 前向传播与激活:bfloat16 / fp16
- 损失缩放:解决梯度下溢问题
- 工具:
torch.cuda.amp.autocast+GradScaler
10 总结
- 模型是张量与运算的组合,
nn.Module提供了结构化封装。 - 优化器从最简单的 SGD 演化到 AdamW,反映了对学习率调控与正则化理解的深化。
- 内存与计算核算帮助我们量化训练需求。
- 最终通过 训练循环 将所有要素集成,构成现代深度学习系统的基本执行框架。
- Title: CS336 Notes
- Author: Hao Feng
- Created at : 2025-09-08 18:50:15
- Updated at : 2025-09-08 19:34:53
- Link: https://matt23-star.github.io/2025/09/cs336-notes/
- License: This work is licensed under CC BY-NC-SA 4.0.