这是一篇前置讲,不对应原课的某一次课。原课第 1 讲的 slides 里有一张”大语言模型的内存占用”表(GPT-3 用 FP32 要 651 Gb、用半精度要 325 Gb),之后几乎每个单元都要算这种账:第 3–4 讲的 KV 缓存和 GQA,第 10 讲 PEFT 为什么省显存,第 17 讲分布式训练怎样切分参数和优化器状态,第 18 讲的高效解码与量化。这篇为没有计算机系统背景的读者把这些”账”的基础讲清楚。 依据说明:本讲讲的是通用的计算机知识(二进制、IEEE 754 浮点标准、张量的内存布局),以及几篇被广泛引用的论文里的做法和估算:Micikevicius et al. (2018) 的混合精度训练、Kalamkar et al. (2019) 的 BF16、Micikevicius et al. (2022) 的 FP8 格式、Rajbhandari et al. (2020) ZeRO 里”每个参数 16 字节”的算法、Korthikanti et al. (2022) 的激活值估算。模型配置(GPT-3、Llama-3)取自它们公开的论文和模型说明。 配套代码 Code:
code/prereq1/(6 个脚本 + 1 个模块;需要 numpy 和 PyTorch CPU 版;每个脚本 1–2 秒) 预计阅读时间:3–4 小时(含运行代码) 建议阅读时机:第 1 讲之前(可选),最晚第 3 讲之前。
0. 本讲导航 · How to read this lecture
0.1 为什么要懂这些
训练和使用生成式模型时,你会不断碰到下面这些问题:
- “这个 70 亿参数的模型,我的 24 GB 显卡能跑吗?能微调吗?”
- “为什么大家都用 BF16 训练?为什么还要留一份 FP32 的权重?”
- “上下文从 8K 加到 128K,显存会涨多少?”
- “训练时 loss 突然变成 NaN 了,可能是哪里溢出了?”
- “为什么我的代码里
x.T.view(-1)报错,而reshape不报错?”
这些问题都不需要深奥的理论,只需要知道三件事:数字在计算机里怎样存储(以及这种存储带来的误差)、张量在内存里怎样排布、一个模型的各个部分各占多少字节。这篇前置讲把这三件事从比特讲起。
0.2 学习目标 Learning goals
- 会把整数写成二进制补码;知道各种整数类型的范围,以及溢出时会发生什么。
- 能拆开一个浮点数(符号、指数、尾数),说出 FP32、FP16、BF16、FP8 各自的范围和精度,并解释为什么 BF16 成了训练的主流格式。
- 理解浮点运算的几个陷阱:舍入、溢出 / 下溢、加法不满足结合律、低精度累加停滞、灾难性抵消。
- 说出混合精度训练的四个要点:FP32 主权重、损失缩放、高精度累加、数值稳定的 softmax;知道混合精度到底省了什么。
- 理解张量的存储 + 步长模型,分清视图和复制,会读广播规则。
- 会给一个 Transformer 算账:参数量、推理显存(权重 + KV 缓存)、训练显存(每个参数 16 字节)、激活值。
- 知道”搬数据”和”做计算”的速度差异,理解算术强度的直觉(为前置讲 P3 铺垫)。
0.3 本讲地图 Map
§1 比特、字节与整数:补码、范围、溢出、KB vs KiB、token id 的类型 ← 代码 01
§2 浮点数:三段比特、五种格式、舍入与间距、特殊值、三个陷阱 ← 代码 02
§3 混合精度训练:主权重、损失缩放、高精度累加、稳定的 softmax ← 代码 03
§4 张量:存储 + 步长、视图与复制、广播、原地修改 ← 代码 04
§5 给模型算账:参数量、推理显存、训练显存、激活值 ← 代码 05
§6 设备与数据搬运:带宽 vs 算力、算术强度 ← 代码 06
§7 小结 §8 常见误区 §9 练习 §10 延伸阅读 §11 下一篇预告
0.4 先修
只需要会一点 Python,知道”矩阵乘法”是什么。第 1 讲的内容(张量、nn.Module、反向传播)有帮助,但不是必需的:用到的地方都会就地解释。
1. 比特、字节与整数 · Bits, bytes and integers
1.1 一切都是比特
计算机的内存是一长串比特(bit),每个比特只能是 0 或 1。8 个比特组成一个字节(byte),字节是内存地址的最小单位:“内存地址 1000 处”指的是第 1000 个字节。一个数要占多少字节,取决于它的数据类型:int8 占 1 字节,int32 和 float32 占 4 字节,float16 和 bfloat16 占 2 字节。
为了方便书写,人们常用十六进制(hex)表示比特串:每 4 个比特对应一个十六进制数字(0–9、a–f),所以一个字节正好是两个十六进制数字,例如 11111111 = 0xff。
1.2 无符号整数与补码
个比特能表示 种不同的状态。用它们表示非负整数(无符号整数,unsigned)最自然:比特串就是这个数的二进制写法,范围是 。
负数怎么办? 现代计算机几乎都用二进制补码(two’s complement):非负数照常写;负数 存成 。01 第 1 部分:
| 整数 | 8 位补码 | 当作无符号数读 |
|---|---|---|
| 0 | 00000000 | 0 |
| 5 | 00000101 | 5 |
| 127 | 01111111 | 127 |
| −1 | 11111111 | 255 |
| −2 | 11111110 | 254 |
| −128 | 10000000 | 128 |
规律:最高位是 1 就是负数;同一串比特当作无符号数读就是另一个值。补码的好处是加法电路不用区分正负: 在 8 位下是 00000101 + 11111011 = 100000000,第 9 位溢出被丢掉,剩下的正好是 0。
1.3 范围与溢出
| 类型 | 位数 | 范围 |
|---|---|---|
| int8 / uint8 | 8 | [−128, 127] / [0, 255] |
| int16 / uint16 | 16 | [−32,768, 32,767] / [0, 65,535] |
| int32 / uint32 | 32 | [−2,147,483,648, 2,147,483,647] / [0, 4,294,967,295] |
| int64 | 64 | 约 ±9.2 × 10^18 |
超出范围时,硬件不会报错,而是回绕(wrap around):在 NumPy 和 PyTorch 的整数张量里,int8 的 127 + 1 = −128,int32 的 20 亿 + 20 亿 = −294,967,296。Python 自己的 int 则没有上限(它会自动用更多的内存),所以纯 Python 代码里你感觉不到溢出,一放进 NumPy / PyTorch 就可能出事。
GenAI 联系:训练时累计”已经看过多少个 token”,到万亿级别会远远超过 int32(约 21 亿),必须用 int64;位置编号、数据集里的偏移量也要注意。
1.4 KB 还是 KiB?
“千”在计算机里有两种意思:
| 十进制单位(硬盘、网络、厂商标称常用) | 二进制单位(内存常用) | 差别 |
|---|---|---|
| 1 KB = 10^3 字节 | 1 KiB = 2^10 = 1024 字节 | 2.4% |
| 1 MB = 10^6 | 1 MiB = 2^20 | 4.9% |
| 1 GB = 10^9 | 1 GiB = 2^30 | 7.4% |
| 1 TB = 10^12 | 1 TiB = 2^40 | 10.0% |
GPT-3 有 1750 亿个参数,FP32 每个 4 字节,一共 7.0 × 10^11 字节 = 700 GB = 651.9 GiB。第 1 讲 slides 表里写的”651 Gb”其实就是 GiB(并且把小数舍掉了)。算账时先确认单位,到 TB 级别差 10%,足以决定”放不放得下”。
1.5 GenAI 联系:token id 该用几位整数?
分好词的训练数据就是一长串整数(token id),它们的范围由词表大小决定(01 第 4 部分):
| 词表 | 大小 | 够用的最小无符号类型 |
|---|---|---|
| 第 1 讲农场语言 | 56 | uint8 |
| GPT-2 | 50,257 | uint16(最大 65,535) |
| Llama 2 | 32,000 | uint16 |
| Llama 3 | 128,256 | uint32 |
GPT-2 的词表刚好塞进 uint16,所以很多训练代码(例如 Karpathy 的 nanoGPT)把分好词的数据集存成 uint16 的二进制文件,比默认的 int64 省 4 倍:100 亿个 token 用 uint16 是 18.6 GiB,用 int64 是 74.5 GiB。Llama 3 的词表超过了 65,535,就得用 32 位。数据类型的选择,是由”值的范围”决定的。
2. 浮点数 · Floating-point numbers
2.1 从科学计数法说起
整数不够用:神经网络的权重是 0.0123 这样的小数,梯度可能是 1e-8,激活值可能是 1e4。我们需要一种能同时表示很大和很小的数的格式。答案是科学计数法: 由三部分组成——符号(正)、尾数(6.02,决定精度)、指数(23,决定大小)。
计算机用的是二进制科学计数法:
“浮点”(floating point)的意思是小数点的位置随指数”浮动”:同样 位尾数,可以表示 (很大)也可以表示 (很小),相对精度大致不变。
2.2 三段比特
一个浮点数的比特分成三段:
| 符号 s (1 位) | 指数字段 E 位 | 尾数字段 M 位 |
- 符号:0 正 1 负。
- 指数字段:存的是”实际指数 + 偏置”,偏置 bias (FP32 是 127)。这样指数字段是一个非负整数,比较大小更方便。
- 尾数字段:二进制小数点后面的 位。正规数(normal)的最高位一定是 1(就像科学计数法里尾数总是写成 1.xxx),所以这个 1 不用存,叫”隐含位”。 位尾数实际有 位精度。
于是正规数的值是:
例子:把 π 存进五种格式(02 第 1 部分):
| 格式 | 符号 | 指数 | 尾数 | 还原的值 | 相对误差 |
|---|---|---|---|
| FP32 | 0 | 10000000 | 10010010000111111011011 | 3.141592741 | 2.8e-08 |
| FP16 | 0 | 10000 | 1001001000 | 3.140625 | 3.1e-04 |
| BF16 | 0 | 10000000 | 1001001 | 3.140625 | 3.1e-04 |
| FP8-E4M3 | 0 | 1000 | 101 | 3.25 | 3.5e-02 |
| FP8-E5M2 | 0 | 10000 | 10 | 3 | 4.5e-02 |
以 BF16 为例:指数字段 ,减去偏置 127 得实际指数 1;尾数 ;值 。π 在 2 和 4 之间,所以所有格式的实际指数都是 1,差别全在尾数的位数。
(有个巧合:FP16 和 BF16 存的是同一个数。π 的二进制尾数是 1001001 000 0111…,第 8 到 10 位恰好是 0,FP16 多出的 3 位尾数没有帮上忙。一般情况下 FP16 比 BF16 精确约 8 倍。)
2.3 五种格式
02 第 2 部分用公式算出每种格式的关键参数,并和 PyTorch 的 torch.finfo 逐项对照,全部一致:
| 格式 | 总位数 | 指数 E | 尾数 M | 最大值 | 最小正规数 | ε = 2^−M | ≈十进制有效位 |
|---|---|---|---|---|---|---|---|
| FP32 | 32 | 8 | 23 | 3.4e38 | 1.2e-38 | 1.2e-07 | 7.2 |
| FP16 | 16 | 5 | 10 | 65504 | 6.1e-05 | 9.8e-04 | 3.3 |
| BF16 | 16 | 8 | 7 | 3.4e38 | 1.2e-38 | 7.8e-03 | 2.4 |
| FP8-E4M3 | 8 | 4 | 3 | 448 | 0.0156 | 0.125 | 1.2 |
| FP8-E5M2 | 8 | 5 | 2 | 57344 | 6.1e-05 | 0.25 | 0.9 |
几个公式( 位指数、 位尾数、偏置 ):
- 机器 ε(machine epsilon)= 1 和”比 1 大的下一个可表示数”之间的距离 = 。它刻画相对精度:任何正规数存进去后,相对误差最多是 。
- 最大值:IEEE 格式把”指数字段全 1”留给 inf 和 NaN,所以最大的实际指数是 ,最大尾数全 1:。FP16:。
- E4M3 是个例外:它不表示 inf,只把”指数全 1 且尾数全 1”留给 NaN,于是能多用一段指数,最大值 。
怎样读这张表:指数位决定范围,尾数位决定精度。
- FP16(IEEE 754-2008 的 binary16)精度不错(3 位多十进制有效数字),但范围小得可怜:最大 65504,最小正规数 6e-5。训练时激活值偶尔超过 65504(溢出成 inf),梯度经常小于 6e-5(精度损失甚至变成 0)。
- BF16(“brain floating point”,Google Brain 为 TPU 设计)用和 FP32 一样多的指数位,只是把尾数从 23 位砍到 7 位——它就是”FP32 的高 16 位”。范围和 FP32 一样大,精度只有 2–3 位十进制有效数字。Kalamkar et al. (2019) 在多种模型上报告,BF16 训练可以达到和 FP32 相当的结果;因为范围和 FP32 一样,通常也不需要损失缩放。深度学习训练里,范围比精度更要命:权重少几位精度,噪声会被随机梯度下降本身的噪声淹没;但一个溢出成 inf 的激活值会让整个训练崩溃。这就是 BF16 取代 FP16 成为主流训练格式的原因。
- FP8(Micikevicius et al., 2022;NVIDIA H100 起硬件支持)有两种:E4M3 精度稍好,通常用于前向的权重和激活;E5M2 范围稍大,通常用于反向的梯度。8 位能表示的数太少,实际使用时总是配合一个缩放因子(scale):先把一个张量整体缩放到 FP8 的范围里,再存储(DeepSeek-V3 等模型已经用 FP8 混合精度训练)。
- 表里没有列出的还有 TF32(A100 张量核心用的内部格式:8 位指数、10 位尾数)和用于推理的 INT8 / INT4 量化(第 18 讲)。
2.4 舍入与间距
可表示的浮点数不是连续的,而是一个个离散的格点。两个相邻格点之间的距离叫 ULP(unit in the last place)。任何运算的结果都要舍入到最近的格点(平局时取尾数为偶数的那个)。02 第 3 部分:
| 数值 | FP32 的间距 | FP16 的间距 | BF16 的间距 |
|---|---|---|---|
| 1 | 1.2e-07 | 0.000977 | 0.0078 |
| 1000 | 6.1e-05 | 0.5 | 4 |
| 10^6 | 0.0625 | 溢出 | 4096 |
间距随数值大小成比例增长:数值每翻一倍,间距也翻一倍。所以相对精度大致不变,绝对精度却随数值变大而变差。两个直接的后果:
- 0.1 + 0.2 ≠ 0.3。0.1 在二进制里是无限循环小数(就像 1/3 在十进制里),只能存一个近似值:0.1 存成 FP32 后是 0.100000001490116…。比较浮点数时永远用”差的绝对值小于某个容差”(
math.isclose、torch.allclose),而不是==。 - 整数也会丢。FP16 能精确表示的最大连续整数是 2048(2049 存进去变成 2048),BF16 是 256(257 → 256),FP32 是 。所以”把位置编号、计数器存成 BF16”一定会出错。
2.5 特殊值:溢出、下溢、次正规数、inf 与 NaN
02 第 4 部分:
| 原始值 | 存进 | 变成 | 类别 |
|---|---|---|---|
| 70000 | FP16 | inf | 溢出 |
| 70000 | BF16 | 70144 | 正规数(只是精度粗) |
| 1e-8 | FP16 | 0 | 下溢 |
| 3e-8 | FP16 | 5.96e-08 | 次正规数 |
| 1e-8 | BF16 | 1.00117e-08 | 正规数 |
| 500 | FP8-E4M3 | 448 | 饱和到最大值 |
| 1e6 | FP8-E5M2 | inf | 溢出 |
- 溢出(overflow):超过最大值就变成 inf。FP16 里 还能存, 就是 inf。inf 参与运算会产生 NaN(not a number):inf − inf、0 × inf、inf / inf 都是 NaN,而 NaN 和任何数运算都还是 NaN——一个 NaN 会传染给后面所有的计算,这就是训练时 loss 突然变成 NaN 的常见原因。
- 下溢(underflow):比最小可表示的正数还小一半,就变成 0。FP16 的 1e-8 → 0。梯度常常就是这么小。
- 次正规数(subnormal):指数字段为 0 时,规定去掉隐含的 1,值 ,让数可以”逐渐”降到 0,而不是从最小正规数直接跳到 0。代价是精度越来越差(3e-8 存成了 5.96e-8,误差 100%)。
- FP8 的越界处理不统一:本机 PyTorch 把超出 E4M3 范围的 500 饱和成 448(这种格式没有 inf),而 E5M2 的 1e6 变成 inf。不同框架和硬件的约定不完全一样,所以实际使用时会先用缩放因子把数值缩到范围内。
2.6 浮点运算的三个陷阱
陷阱一:加法不满足结合律。 在 FP32 里,,而 。原因: 附近 FP32 的间距是 8,加 1 被舍入掉了。同样三个数,换个加法顺序结果就不同。后果之一是:并行计算的结果可能和串行不同(多线程、多 GPU 的求和顺序不确定),这是深度学习里”同样的代码两次运行结果有微小差异”的根源之一。
陷阱二:低精度累加会停滞。 把 10,000 个 0.0001 逐个加起来(精确答案 1.0):
| 格式 | 逐个累加 | torch.sum |
|---|---|---|
| FP32 | 1.00005 | 1 |
| FP16 | 0.25 | 1 |
| BF16 | 0.03125 | 1 |
FP16 加到 0.25 就停住了:0.25 附近 FP16 的间距是 ,0.0001 不到间距的一半,加上去被舍入掉——“加了等于没加”。BF16 的尾数更少,停得更早。torch.sum 没有这个问题:它不是从左往右逐个累加,而是分块求和,并且在 CPU 上对 FP16/BF16 输入用 FP32 做中间累加,最后才舍入回低精度。这引出低精度计算的通用原则:存储可以低精度,累加要用高精度(§3.4)。
陷阱三:灾难性抵消。 两个非常接近的数相减,有效数字会大量抵消。经典例子是方差的”一遍算法” 。对均值 10000、方差约 1 的 10 万个数(FP32):
| 算法 | 方差 |
|---|---|
| (一遍) | −8.000000 |
| (两遍) | 1.000258 |
| FP64 参照 | 1.000257 |
,FP32 在 附近的间距是 8;两个约 的数相减,真正的差(约 1)比舍入误差还小,结果完全不可信——方差居然算成了负数。先减均值再平方就没有这个问题。LayerNorm、BatchNorm 的实现都用稳定的算法(两遍或 Welford 在线算法),并且通常在 FP32 里算统计量,哪怕输入是 BF16。
3. 混合精度训练 · Mixed-precision training
3.1 为什么要用低精度
低精度的好处有三个:
- 省内存:BF16 每个数 2 字节,是 FP32 的一半。
- 省带宽:同样的数据量,读写的字节数减半;很多运算的瓶颈恰恰是读写内存(§6)。
- 算得快:GPU 的张量核心(Tensor Core)专为低精度矩阵乘法设计。第 1 讲的 slides 引用 PyTorch 文档说,A100 上 float16 矩阵乘法的峰值性能是 float32 的 16 倍。
但 §2 已经看到,低精度会带来溢出、下溢、舍入和累加停滞。混合精度训练(mixed-precision training,Micikevicius et al., 2018)的思想是:大部分计算用低精度,少数对精度敏感的地方用 FP32。它有四个要点。
3.2 要点一:FP32 主权重
03 第 1 部分:从 开始,做 1000 次 (正确答案 2.0):
| 方案 | 最终权重 |
|---|---|
| 权重直接存成 FP32 | 2.000047 |
| 权重直接存成 FP16 | 1.976562 |
| 权重直接存成 BF16 | 1.000000 |
| BF16 计算 + FP32 主权重 | 2.000000(主权重 2.000047) |
BF16 在 1.0 附近的间距是 ,更新量 0.001 不到间距的一半,每次都被舍入回原值,权重原地踏步。训练后期”学习率 × 梯度”常常比权重本身小 4 到 6 个数量级,这种情况非常普遍。
解决办法:保留一份 FP32 主权重(master weights),优化器的更新加在主权重上;每一步前向和反向时,再把主权重转换成一份 BF16 副本来做计算。小更新在 FP32 里慢慢累积,累积到足够大时,BF16 副本自然就变了。
3.3 要点二:损失缩放(FP16 才需要)
03 第 2 部分模拟 10 万个梯度,数量级在 1e-10 到 1e-2 之间均匀分布(对数尺度上):
| 缩放因子 S | FP16 下溢成 0 的比例 | 溢出成 inf 的比例 | 非零梯度的相对误差(中位数) |
|---|---|---|---|
| 1 | 0.308 | 0.000 | 6.5e-04 |
| 2^8 | 0.008 | 0.000 | 2.8e-04 |
| 2^16 | 0.000 | 0.000 | 1.9e-04 |
| 2^24 | 0.000 | 0.051 | 1.7e-04 |
不缩放时,30.8% 的梯度在 FP16 里直接变成 0。损失缩放(loss scaling)的做法是:反向传播之前把损失乘以一个大数 。由链式法则,所有梯度也都被乘以 ,于是从下溢区被”抬”进了可表示的范围;更新参数之前,再在 FP32 里除以 。 时下溢比例降到 0,也没有溢出。
太大又会溢出( 时 5.1% 变成 inf)。实践中用动态损失缩放:从一个大的 开始,一旦某一步出现 inf / NaN,就跳过这一步并把 减半;连续很多步没出问题,再把 加倍。PyTorch 的 torch.amp.GradScaler 做的就是这件事。
BF16 不需要损失缩放:同一批梯度存成 BF16,一个都没下溢——它的指数位和 FP32 一样多。这是 BF16 在训练中受欢迎的又一个原因。
3.4 要点三:高精度累加
03 第 3 部分:两个随机的 FP16 向量做点积,用 FP16 累加器和 FP32 累加器分别计算(误差以”乘积的典型大小”为单位):
| 长度 n | FP16 累加的误差 | FP32 累加的误差 |
|---|---|---|
| 256 | 1.5e-03 | 3.2e-07 |
| 4,096 | 2.8e-03 | 1.5e-07 |
| 65,536 | 4.1e-03 | 3.9e-06 |
FP16 累加的误差随长度增长(§2.6 的累加停滞是它的极端情况)。矩阵乘法 的每个元素都是一个长度为 的点积, 在大模型里是几千到几万。所以张量核心的规则是:输入用 BF16 / FP16 / FP8,乘积在 FP32 里累加,最后再舍入输出。归约类运算(求和、求均值、LayerNorm 的统计量、softmax 的分母)也都应该在 FP32 里做。
3.5 要点四:数值稳定的 softmax
03 第 4 部分:logits = [10, 11, 12](FP16)。 超过了 FP16 的最大值,变成 inf,于是朴素的 softmax 得到 [0, 0, NaN]。由于
取 ,最大的指数变成 ,永远不会溢出,结果 [0.0901, 0.2448, 0.6655](FP64 参照 [0.0900, 0.2447, 0.6652])。同样的技巧用在 (log-sum-exp)上,就是第 1 讲交叉熵模块的稳定写法;第 18 讲的 FlashAttention 要把它推广成”在线 softmax”,一块一块地处理注意力分数。
3.6 混合精度到底省了什么?
03 第 5 部分:用 Adam 训练时每个参数要存什么——
| 方案 | 每个参数存什么(字节) | 合计 |
|---|---|---|
| 全 FP32 | 权重 4 + 梯度 4 + Adam 一阶矩 4 + 二阶矩 4 | 16 |
| 混合精度 | BF16 权重 2 + BF16 梯度 2 + FP32 主权重 4 + 一阶矩 4 + 二阶矩 4 | 16 |
一个反直觉的结论:混合精度并不省参数和优化器状态的内存。多存的那份 BF16 权重抵消了梯度变小省下的空间(这正是 ZeRO 论文里”每个参数 16 字节”的来历)。它真正省的是:
- 激活值:以 BF16 保存,大约减半(§5.5 会实测,并看到一个意外);
- 时间:BF16 矩阵乘法比 FP32 快得多,读写的数据也少一半。
参数和优化器状态的内存要靠别的办法省:把它们切分到多块 GPU 上(ZeRO / FSDP,第 17 讲),或者只训练很少的参数(LoRA,第 10 讲),或者把优化器状态也量化成 8 位(8-bit Adam)。
4. 张量 · Tensors
4.1 从标量到张量
张量(tensor)就是多维数组:标量是 0 维张量,向量是 1 维,矩阵是 2 维;一批图像是 4 维(批大小 × 通道 × 高 × 宽);Transformer 里的一批隐藏状态是 3 维(批大小 × 序列长度 × 隐藏维度)。张量的形状(shape)就是每一维的长度,例如 (32, 1024, 4096)。
但内存是一维的一长串字节。多维的张量怎样放进一维的内存?
4.2 存储 + 步长
PyTorch(以及 NumPy)的答案是:张量 = 一块一维的存储(storage)+ 一组把下标翻译成位置的规则。规则由三样东西组成:形状、步长(stride)、存储偏移(storage offset)。元素 在存储中的位置是
04 第 1 部分:x = torch.arange(12).reshape(3, 4) 的存储就是 0, 1, …, 11 按顺序排列,步长是 (4, 1)——下一行要跳过 4 个元素,下一列只跳 1 个。 在位置 。
| 张量 | 形状 | 步长 | 连续? | 存储偏移 |
|---|---|---|---|---|
x | (3, 4) | (4, 1) | 是 | 0 |
x.T(转置) | (4, 3) | (1, 4) | 否 | 0 |
x[1](第 2 行) | (4,) | (1,) | 是 | 4 |
x[:, 1](第 2 列) | (3,) | (4,) | 否 | 1 |
x[:, ::2](隔列取) | (3, 2) | (4, 2) | 否 | 0 |
这 4 个张量和 x 共享同一块存储:转置、取行、取列、隔列取,都只改步长和偏移,一个字节也不搬。它们是同一块存储的不同视图(view)。
步长按”行优先”递减(最后一维步长为 1,前一维步长 = 后面所有维度长度之积)时,张量叫连续的(contiguous)。转置后的步长是 (1, 4):同一行的相邻元素在内存里隔着 4 个位置。这对性能有影响:连续读取内存最快,跳着读会浪费缓存(前置讲 P3)。
4.3 视图 vs 复制
04 第 2 部分:
| 操作 | 视图还是复制 |
|---|---|
x.view(12) | 视图 |
x.T.view(12) | 报错:步长无法表示成新形状 |
x.T.reshape(12) | 复制(因为 x.T 不连续) |
x.T.contiguous() | 复制 |
x.clone() | 复制 |
x.to(torch.bfloat16) | 复制(换了类型) |
view要求数据在内存里的排布能被”重新解释”成新形状。转置后的张量摊平成一维,元素顺序是 0, 4, 8, 1, 5, 9, …,这不是原存储里的任何一种等间距排列,所以view做不到。reshape能视图就视图,不能就复制。方便,但你不知道它到底复制了没有;在意内存和性能时,用view(保证不复制)或显式的contiguous()。- 视图共享内存:
v = x[0]; v[0] = 100之后x[0, 0]也变成了 100。这既是省内存的手段,也是隐蔽 bug 的来源。
4.4 一个小切片拖住整块存储
04 第 3 部分:big = torch.zeros(4_000_000)(15.3 MiB);small = big[:10];del big。small 只有 40 字节,但它拖住的存储仍然是 15.3 MiB——del 只删掉了一个名字,只要还有视图指向那块存储,它就不会被释放。big[:10].clone() 才只占 40 字节。
GenAI 联系:训练循环里把 loss 或某个中间结果存进列表(记日志、画曲线)时,要用 .item()(变成 Python 数)或 .detach().clone();直接存张量,可能把整个计算图和大块激活值一起留在内存里,显存随步数缓慢上涨——这是最常见的”内存泄漏”之一。
4.5 广播
04 第 4 部分:形状 (3, 1) 和 (1, 4) 的张量相加,得到 (3, 4)。广播规则:
- 从最后一维往前对齐;缺的维度当作 1;
- 每一维要么相等,要么其中一个是 1——是 1 的那个被”拉伸”到另一个的长度;
- 否则报错。
| 形状 A | 形状 B | 结果 |
|---|---|---|
| (3, 1) | (1, 4) | (3, 4) |
| (2, 3, 4) | (4,) | (2, 3, 4) |
| (2, 3, 4) | (3, 1) | (2, 3, 4) |
| (5, 1, 7) | (1, 6, 1) | (5, 6, 7) |
| (3, 4) | (3,) | 不能广播 |
最后一行是最常见的形状 bug:想给每一行加一个数,写成了形状 (3,),但对齐的是最后一维(4 vs 3)。应该写成 (3, 1)。
广播几乎不占额外内存:col.expand(3, 4) 的步长是 (1, 0)——被拉伸的维度步长为 0,所有”复制品”指向同一个内存位置,存储只有 3 个数。
4.6 原地修改与反向传播
04 第 5 部分:a = torch.sigmoid(x); a.mul_(2); a.sum().backward() 会报错:“反向需要的某个变量被原地操作修改了”。原因:sigmoid 的导数是 ,autograd 在前向时保存了输出 留给反向用;mul_ 原地改掉了 ,反向时就会算错。PyTorch 给每个张量维护一个”版本号”,发现被保存的张量版本变了就报错。写成 b = torch.sigmoid(x) * 2(产生新张量)就没问题。
这个例子说明了激活值为什么占内存:反向需要的中间结果必须原封不动地保留到反向传播用完为止。§5.5 会测量它们有多大。
5. 给模型算账 · Memory accounting
5.1 参数量公式
第 2、4 讲会讲清 Transformer 的每个部件;这里只需要知道一个仅解码器的 Transformer 由 层组成,每层有一个注意力子层和一个前馈(MLP)子层,外加输入的嵌入表和输出层。设隐藏维度 、前馈层中间维度 、词表大小 :
| 部件 | 参数量 |
|---|---|
| 注意力:Q、K、V、O 四个投影 | 普通多头注意力:;GQA:( = KV 头数 × 头维度) |
| 前馈层 | 普通 MLP:;SwiGLU(多一个门控矩阵): |
| 归一化层 | 每层 2 个,每个 (RMSNorm)或 (LayerNorm) |
| 嵌入 + 输出层 | (共享时)或 |
GPT 式的经典配置 :每层 ,全模型约 。这个""值得记住:它让你一眼估出一个模型的大小。
怎样确认公式没写错? 最可靠的办法是真建一个小模型,让框架数。05 第 1 部分按配置用 nn.Linear、nn.LayerNorm / nn.RMSNorm 搭出所有参数(不需要前向),sum(p.numel()) 和公式逐个相等:小 GPT 式配置 106,496 个,小 Llama 式配置 143,040 个。漏一个偏置、多算一个归一化层,这里立刻就能发现。
5.2 著名模型的参数都花在哪里
| 模型 | 公式算出 | 公开的数字 | 注意力 | 前馈层 | 嵌入 + 输出层 |
|---|---|---|---|---|---|
| GPT-3 175B(, ) | 174.58B | 175B | 33.2% | 66.4% | 0.4% |
| Llama-3 8B(, , 8 个 KV 头) | 8.03B | 8.03B | 16.7% | 70.2% | 13.1% |
| Llama-3 70B(, , 8 个 KV 头) | 70.55B | 70.6B | 17.1% | 79.9% | 3.0% |
- 公式和公开数字吻合(GPT-3 的可学习位置嵌入约 2500 万个参数没算进去,只占 0.01%)。
- 参数的大头在前馈层:66%–80%。Llama 用 GQA 让 K、V 的投影矩阵变小、前馈层又更宽,注意力只占约 17%。
- Llama-3 的词表有 12.8 万个,8B 模型里嵌入和输出层就占了 13%;模型越大,这个比例越小。
5.3 推理显存:权重 + KV 缓存
推理(只做前向)至少要放下权重:
| 模型 | BF16(2 字节) | INT8(1 字节) | INT4(0.5 字节) |
|---|---|---|---|
| Llama-3 8B | 15.0 GiB | 7.5 GiB | 3.7 GiB |
| Llama-3 70B | 131.4 GiB | 65.7 GiB | 32.9 GiB |
8B 模型用 BF16,一块 24 GB 的消费级显卡放得下;70B 需要两块 80 GB 的卡,或者量化到 INT4(第 18 讲)。
生成文字时,还要存 KV 缓存:注意力在生成第 个 token 时要用到前面所有 token 的键(K)和值(V),把它们存下来就不必每一步重算(第 3 讲)。它的大小是
| 模型(BF16,批大小 1) | 每个 token | 8K 上下文 | 128K 上下文 |
|---|---|---|---|
| Llama-3 8B(GQA,8 个 KV 头) | 128 KiB | 1.0 GiB | 16.0 GiB |
| Llama-3 8B(假如 32 个头都有 KV) | 512 KiB | 4.0 GiB | 64.0 GiB |
| Llama-3 70B(GQA,8 个 KV 头) | 320 KiB | 2.5 GiB | 40.0 GiB |
| Llama-3 70B(假如 64 个头都有 KV) | 2.5 MiB | 20.0 GiB | 320.0 GiB |
- KV 缓存随上下文线性增长:70B 模型在 128K 上下文下,一条序列的 KV 缓存就有 40 GiB,是权重的 30%;同时服务 8 个用户就是 8 倍。长上下文推理的显存瓶颈常常是 KV 缓存,而不是权重。
- GQA(第 4 讲)让多个查询头共享同一组 K/V:8B 模型 KV 缓存省 4 倍,70B 省 8 倍。这是现代 LLM 几乎都用 GQA 的主要原因。
5.4 训练显存:每个参数 16 字节
训练要比推理多存三样东西:梯度、优化器状态、激活值。用 Adam + 混合精度(§3.6),不算激活值,每个参数 16 字节:
| 模型 | 参数 + 梯度 + 优化器状态 | 至少要几块 80 GiB 的 GPU(只放这些) |
|---|---|---|
| Llama-3 8B | 119.7 GiB | 1.5 |
| Llama-3 70B | 1.0 TiB | 13.1 |
| GPT-3 175B | 2.5 TiB | 32.5 |
训练比推理贵一个数量级:8B 模型推理只要约 15 GiB,全量训练光是这些状态就要约 120 GiB,一块 80 GB 的卡都放不下。所以哪怕是”小”模型的全量训练也要多卡,并把状态切分到各卡上(ZeRO / FSDP,第 17 讲);只想微调时,LoRA 只训练很少的参数,梯度和优化器状态几乎可以忽略(第 10 讲)。
5.5 激活值:实测与估算
激活值是前向时为反向保存的中间结果(§4.6)。它和参数不同:参数量和输入大小无关,激活值和批大小 × 序列长度成正比。
05 第 5 部分用 PyTorch 的 torch.autograd.graph.saved_tensors_hooks 拦截每一个”被保存”的张量,统计字节数(按存储地址去重,排除参数本身)。
一个 MLP:Linear(512 → 2048) → ReLU → Linear(2048 → 512) → 交叉熵:
| 批大小 B | 实测(FP32) | 手算 |
|---|---|---|
| 32 | 393,476 | 393,472 |
| 64 | 786,948 | 786,944 |
| 128 | 1,573,892 | 1,573,888 |
实测和手算只差一个与批大小无关的 4 字节标量。要保存的是:第一个线性层的输入 (算权重梯度要用)、ReLU 的输出(同时被 ReLU 的反向和第二个线性层的反向用到,只存一份)、softmax 的对数概率,外加 int64 的标签。
BF16 autocast 下的意外。 在 torch.autocast(device_type="cpu", dtype=torch.bfloat16) 里重测,B=128 的总数反而从 1.57 MB 涨到了 3.02 MB。用两个批大小拆开”每样本部分”和”固定部分”才看清:
| 每个样本 | 与批大小无关的固定部分 | |
|---|---|---|
| FP32 | 12,296 字节 | 4 字节 |
| BF16 autocast | 7,176 字节 | 2,097,156 字节 |
每个样本的激活值确实减少了(线性层的输入和 ReLU 输出以 BF16 保存;交叉熵前的 logits 被 autocast 留在 FP32,所以不是正好减半);多出来的固定部分正好是第二个线性层权重的 BF16 副本(2048 × 512 × 2 = 2,097,152 字节)——autocast 每一步都把 FP32 权重临时转成 BF16 副本,反向要用到的那份副本也被保存了。批大小或序列长度一大,每样本省下的部分就占了主导,这才是”混合精度省激活值”的真实含义。(第一个线性层的权重副本不需要保存:输入 不需要梯度,反向用不到它。)
第 1 讲的 RNN-LM:每个 token 的激活值基本是常数(886–906 字节),总量 ∝ 批大小 × 序列长度。
大 Transformer。 Korthikanti et al. (2022) 估算,一个标准 Transformer 层(16 位激活、不做重计算)要保存约
( 序列长度、 批大小、 隐藏维度、 注意力头数)。“34” 来自线性层的输入、归一化、激活函数和 dropout 掩码;"" 来自注意力分数矩阵,随 增长。
| 模型 | 序列长度、批大小 | 每层 | 全部层 | 注意力分数矩阵的占比 |
|---|---|---|---|---|
| GPT-2 small(124M) | s=1024, b=8 | 684 MiB | 8.0 GiB | 70% |
| GPT-3 175B | s=2048, b=1 | 2.7 GiB | 256.5 GiB | 70% |
GPT-3 只处理一条 2048 token 的序列,激活值就要 256.5 GiB,接近它全部 BF16 权重(326 GiB);而实际训练每步要处理成百上千条序列。两个常用的解法:激活重计算(activation checkpointing,只存每层的输入,反向时重算其余部分,用大约三分之一的额外计算换大量内存,第 17 讲);FlashAttention(根本不把 的注意力矩阵存下来,第 18 讲)。
5.6 一张总账
把 §5.3–§5.5 合起来:
| 推理 | 训练 | |
|---|---|---|
| 权重 | 2 字节/参数(BF16),量化后 0.5–1 字节 | 2(BF16)+ 4(FP32 主权重) |
| 梯度 | — | 2 |
| 优化器状态 | — | 8(Adam 的两个矩,FP32) |
| 随输入增长的部分 | KV 缓存 ∝ 层数 × KV 头数 × 序列长度 × 批大小 | 激活值 ∝ 层数 × 序列长度 × 批大小(注意力部分 ∝ 序列长度²) |
有了这张表,“这个模型能不能在我的机器上跑 / 训练”就能在动手之前回答。练习 4–6 让你自己算几个例子。
6. 设备与数据搬运 · Devices and data movement
6.1 CPU、GPU 与它们的内存
PyTorch 的张量住在某个设备(device)上:cpu、cuda(NVIDIA GPU)、mps(Apple GPU)。参与同一个运算的张量必须在同一个设备上。.to(...) 在”已经满足要求”时直接返回原张量(t.to("cpu") is t 为 True),否则返回一个新的副本——换设备或换类型都会复制。
在 NVIDIA 的机器上,CPU 内存和 GPU 显存是两块不同的物理内存,中间隔着 PCIe 总线(每秒几十 GB),而显存自己的带宽是每秒几 TB(H100 约 3.35 TB/s)。所以训练代码会尽量让数据一次性搬到 GPU 上、在 GPU 上待着;循环里每调用一次 .item() 或 .cpu(),都会让 GPU 停下来等待、并把数据搬回 CPU。Apple 芯片的 CPU 和 GPU 共享同一块物理内存(统一内存),但 PyTorch 的 .to("mps") 仍然会复制一份。
6.2 带宽 vs 算力:算术强度
06 第 2 部分在一个 CPU 核心上测了三个操作(⏱ 这些是本机的计时,每台机器不同,只看数量级):
| 操作 | 本机速度(⏱) |
|---|---|
| 复制 256 MiB | 约 135 GB/s(读 + 写) |
| 逐元素加法(6400 万个数) | 约 10 GFLOP/s(约 120 GB/s) |
| 1024 × 1024 矩阵乘法 | 约 3000 GFLOP/s |
同一个核心,矩阵乘法每秒做的浮点运算是逐元素加法的约 300 倍。原因在于算术强度(arithmetic intensity)——每读写一个字节能做多少次浮点运算:
- 逐元素加法 :每个元素 1 次运算,读写 12 个字节(读两个 FP32、写一个),强度 FLOP/字节。它的速度完全被内存带宽卡死:实测 120 GB/s,几乎等于纯复制的速度。
- 矩阵乘法 (): 次运算,读写约 字节,强度 FLOP/字节()。每个数被重复使用 次,瓶颈变成了计算本身。
算术强度低的运算叫访存受限(memory-bound),高的叫计算受限(compute-bound)。这个区分在 GPU 上同样成立,而且差距更大;前置讲 P3 会用 roofline 模型把它画出来。它直接解释了很多设计:为什么要做算子融合(把几个逐元素运算合成一个,少读写几遍内存)、为什么 FlashAttention 快(减少读写显存的次数)、为什么生成文字时批大小为 1 的解码效率低(矩阵-向量乘法的算术强度只有约 0.5,第 18 讲)。
一个提醒:这台 Mac 的 CPU 单核矩阵乘法有约 3 TFLOP/s,是因为 Apple 芯片的 CPU 带有专门的矩阵运算单元,所以本机 GPU(MPS)的 2048³ 矩阵乘法只比 CPU 单核快约 1.6 倍。普通 CPU 单核通常在一百 GFLOP/s 量级;数据中心 GPU(H100)的 BF16 张量核心标称约 1000 TFLOP/s。不要拿笔记本的数推断这些硬件。
6.3 搬运与转换的代价
06 第 3 部分(⏱):
- FP32 → BF16 的类型转换,读写速度只有约 33 GB/s,比纯复制还慢:每个数都要读出来、舍入、写到一块新内存里。autocast 每一步都要把权重转一次,这也是一笔开销。
- 本机 CPU → Apple GPU 复制 256 MiB 约 6 ms(约 43 GB/s),反方向约 7 ms。
7. 小结 · Summary
- 整数用二进制补码存储,超出范围会回绕而不是报错;token id 的类型由词表大小决定(GPT-2 用 uint16,Llama 3 要 32 位)。
- 浮点数 = 符号 + 指数 + 尾数:指数位决定范围,尾数位决定精度。FP16 精度较好但范围小(最大 65504);BF16 范围和 FP32 一样、精度只有 2–3 位十进制有效数字;FP8 要配合缩放因子使用。
- 可表示的数是离散的格点,间距随数值成比例增长:0.1 + 0.2 ≠ 0.3,FP16 里 2049 → 2048。超出范围变成 inf,太小变成 0;一个 NaN 会传染给后面所有的计算。
- 浮点运算的三个陷阱:加法不满足结合律;低精度累加会停滞(10,000 个 0.0001 在 FP16 里加到 0.25 就停了);灾难性抵消(一遍法算方差得到 −8)。原则:存储可以低精度,累加要用高精度。
- 混合精度训练:FP32 主权重(BF16 权重上的小更新会被吞掉)、损失缩放(FP16 才需要;不缩放时 30% 的梯度下溢)、FP32 累加、减最大值的 softmax。它不省参数和优化器状态(仍是 16 字节/参数),省的是激活值和时间。
- 张量 = 存储 + 步长。转置、切片是视图(共享内存);
view不复制,reshape可能复制;小切片会拖住整块存储;广播靠步长 0 实现,不占内存。 - 算账:GPT 式模型约 个参数,大头在前馈层;推理 = 权重(BF16 2 字节/参数)+ KV 缓存(随上下文线性增长,GQA 省 4–8 倍);训练 = 16 字节/参数 + 激活值(∝ 批大小 × 序列长度,注意力部分 ∝ 序列长度²)。
- 搬数据常常比算数据更慢:算术强度决定一个运算是访存受限还是计算受限;逐元素运算和矩阵乘法的效率可以差几百倍。
8. 常见误区 · Common pitfalls
- “BF16 比 FP16 更精确,因为它更新。” 恰好相反:BF16 的尾数更少,精度更低;它的优势是范围大。
- “用了混合精度,显存就减半了。” 参数 + 梯度 + Adam 状态仍是 16 字节/参数;省的主要是激活值,而且 autocast 还会多存权重的低精度副本。
- “浮点数可以用 == 比较。” 用容差比较;两次运行、两种求和顺序的结果都可能有微小差别。
- “梯度检查、方差这种小事用 FP16 也行。” 有限差分和方差恰恰是对精度最敏感的计算,要用 FP64 / FP32 和稳定的算法。
- “
reshape和view一样。”reshape可能悄悄复制;view保证不复制,做不到就报错。 - “
del x就释放了 x 的内存。” 只要还有视图或计算图引用那块存储,它就不会释放;记日志时用.item()。 - “(3, 4) 的矩阵加上长度为 3 的向量,就是给每行加一个数。” 广播从最后一维对齐,这会报错;要写成 (3, 1)。
- “推理显存就是权重的大小。” 长上下文、大批量时,KV 缓存可以和权重一样大甚至更大。
- “GPU 的算力越大,所有运算都越快。” 访存受限的运算(逐元素运算、批大小为 1 的解码)只看内存带宽,算力再大也用不上。
- “GB 和 GiB 差不多。” 到 TB 级别差 10%,足以决定放不放得下。
9. 练习 · Exercises
练习 1(补码与溢出)。 (a) 写出 −37 的 8 位补码。(b) int8 里 100 + 100 等于多少?(c) 为什么 int8 的最小值是 −128 而不是 −127?
参考答案
(a) 。可以用 num_lab.int_bits(-37, 8) 验证。
(b) 200 超出了 127,回绕成 。
(c) 8 位有 256 种状态。补码里 0 只有一种写法(00000000),剩下 255 种分给正负数:正数 1–127(最高位 0),负数占了所有最高位为 1 的 128 种(10000000 到 11111111),即 −128 到 −1。所以负数比正数多一个。
练习 2(手拆 FP16)。 写出 1.5、−2.0、0.1 的 FP16 比特(符号 | 指数 | 尾数),并说出 0.1 存进去后变成了多少。
参考答案
FP16 的偏置是 15。
- :指数字段 ,尾数 。比特:
0 | 01111 | 1000000000。 - :符号 1,指数字段 ,尾数全 0。比特:
1 | 10000 | 0000000000。 - :指数字段 ;尾数是 的二进制小数 截取 10 位并舍入,得 。存进去的值 。
验证:num_lab.decompose(0.1, "FP16")(本机输出 ('0', '01011', '1001100110')、0.0999755859375)。
练习 3(设计一种格式)。 假设有一种 8 位格式 E3M4(3 位指数、4 位尾数,按 IEEE 的规则保留 inf/NaN)。它的偏置、最大值、最小正规数、ε 各是多少?它适合存什么?
参考答案
偏置 。最大实际指数 ,最大值 ;最小正规数 ;ε 。范围只有 [0.25, 15.5](不算次正规数),精度比 E4M3 好一点。它只适合存范围很窄、事先知道大小的数(例如已经按缩放因子归一化过的值)。用 num_lab.format_stats(FloatFormat("E3M4", 3, 4, True, torch.float32)) 可以验证这些数(本机输出 max 15.5、min_normal 0.25、eps 0.0625)。这个练习说明了 FP8 两种格式的取舍:每多一位指数,范围翻倍再平方,精度减半。
练习 4(KV 缓存)。 用 Llama-3 8B 服务 16 个用户,每人上下文 32K token,BF16 存 KV 缓存。(a) KV 缓存一共多大?(b) 加上权重,一块 80 GB 的 GPU 放得下吗?(c) 如果模型不用 GQA 呢?
参考答案
(a) 每个 token 字节 = 128 KiB。 GiB。
(b) 权重 15.0 GiB + KV 64 GiB = 79 GiB,刚好挤得下,但没有给激活值和其他开销留空间,实际要减少并发数或上下文。
(c) 不用 GQA 时 KV 缓存是 4 倍,256 GiB,一块卡远远放不下。这就是为什么推理服务系统(如 vLLM)要精细管理 KV 缓存(PagedAttention),以及为什么 GQA、MQA、KV 缓存量化这么重要。
练习 5(训练显存)。 一个 10 亿参数的模型,用 Adam 训练。(a) 全 FP32 和混合精度各要多少显存放参数、梯度和优化器状态?(b) 如果改用不带动量的 SGD 呢?(c) 为什么实际训练时显存占用往往比 (a) 的答案大得多?
参考答案
(a) 两者都是 16 字节/参数, GiB。
(b) 纯 SGD 没有优化器状态:全 FP32 是 8 字节/参数(权重 + 梯度)约 7.5 GiB;混合精度是 BF16 权重 2 + BF16 梯度 2 + FP32 主权重 4 = 8 字节/参数,同样约 7.5 GiB。(代码里的 sgd16 方案不存主权重,只有 4 字节/参数,但 §3.2 说明了这样会让小更新丢失。)
(c) 还有激活值(∝ 批大小 × 序列长度,序列长时注意力部分 ∝ 序列长度²)、临时缓冲区、内存碎片,以及框架自身的开销。激活值往往是最大的一块(§5.5)。
练习 6(视图与复制)。 x = torch.arange(24).reshape(2, 3, 4)。(a) x.permute(2, 0, 1) 的形状和步长是什么?是连续的吗?(b) 对它调用 .view(-1) 会怎样?.reshape(-1) 呢?.reshape(-1) 复制了多少字节?(c) x[:, 1, :] 的步长和存储偏移是多少?
参考答案
(a) 原步长 (12, 4, 1)。permute 按新顺序重排维度和步长:形状 (4, 2, 3),步长 (1, 12, 4),不连续。
(b) view(-1) 报错(和 §4.3 的转置一样);reshape(-1) 复制出一个新的连续张量:24 个 int64,共 192 字节。
(c) 形状 (2, 4),步长 (12, 1),存储偏移 4(跳过第 0 个”中间维”的 4 个元素)。可以用 t.stride()、t.storage_offset() 验证。
练习 7(损失缩放的范围)。 假设所有梯度都在 之间。要让它们乘以 后在 FP16 里既不下溢到次正规区(最小正规数约 )也不溢出(65504), 应该在什么范围?2 的哪个幂次合适?
参考答案
下限:,即 。上限:,即 。2 的幂次里 到 都可以;PyTorch 的 GradScaler 默认的初始缩放因子正是 。用 2 的幂次的好处是:乘以 只改变指数、不改变尾数,缩放和反缩放本身不引入舍入误差。
练习 8(灾难性抵消)。 用 FP32 计算 在 处的值(精确值约 0.5)。为什么会错?怎样改写?
参考答案
本机运行:(np.float32(1) - np.cos(np.float32(1e-4))) / np.float32(1e-4)**2 得到 0.0; 时得到 0.4768(误差约 5%)。原因:,而 FP32 在 1 附近的间距约 ,于是 被舍入成 1,。
改写:用 ,,FP32 下得到 0.5000000596。教训:先用数学变形避免两个接近的数相减,而不是寄希望于更高的精度。log-sum-exp、稳定的方差、torch.log1p(算 )都是同一个思路。
练习 9(算术强度与解码)。 生成文字时,如果批大小为 1,每一步的主要计算是”一个 的权重矩阵乘以一个长度为 的向量”(矩阵-向量乘法)。(a) 它的算术强度是多少(权重用 BF16)?(b) 这对生成速度意味着什么?(c) 批大小变成 时呢?
参考答案
(a) 运算 次;读权重 字节(向量很小,可以忽略),强度约 1 FLOP/字节。
(b) 远低于 GPU 的”平衡点”(H100 约每字节几百次运算),所以批大小为 1 的解码是访存受限的:速度由”每秒能把权重从显存读多少遍”决定。粗算:8B 模型 BF16 权重 15 GiB,H100 带宽约 3.35 TB/s,每秒最多读约 200 遍,也就是每秒最多生成约 200 个 token,无论算力多大。
(c) 批大小 时,同一份权重被 个向量复用,强度约 FLOP/字节。所以推理服务系统会把很多用户的请求拼成一批(连续批处理),第 18 讲详细讨论。
10. 延伸阅读 · Further reading
- Goldberg (1991), What Every Computer Scientist Should Know About Floating-Point Arithmetic:浮点数的经典长文。
- IEEE 754-2019:浮点运算标准(binary16 / binary32 / binary64 的定义)。
- Micikevicius et al. (2018), Mixed Precision Training, ICLR:FP32 主权重、损失缩放、FP32 累加。
- Kalamkar et al. (2019), A Study of BFLOAT16 for Deep Learning Training。
- Micikevicius et al. (2022), FP8 Formats for Deep Learning:E4M3 与 E5M2。
- Rajbhandari et al. (2020), ZeRO: Memory Optimizations Toward Training Trillion Parameter Models:训练显存的拆分与”16 字节/参数”。
- Korthikanti et al. (2022), Reducing Activation Recomputation in Large Transformer Models:激活值估算公式与选择性重计算。
- PyTorch 文档:Tensor Views、Broadcasting semantics、Automatic Mixed Precision。
- 本仓库的 CS336 第 2 讲(PyTorch 与资源核算)讲得更深:FP8/FP4、einops、FLOPs 与 MFU、显存的逐项测量。
11. 下一篇预告 · Next
前置讲 P2:生成模型的概率工具箱。 本篇讲的是”数怎样存”;下一篇讲”概率怎样算”。从第 6 讲开始,GAN、扩散模型和 VAE 的推导里会反复出现高斯分布、最大似然、KL 散度、隐变量、证据下界(ELBO)、蒙特卡洛估计、重参数化技巧和马尔可夫链。P2 把这些工具从直觉讲起,每一个都用代码采样验证。