前置讲 P1 · 数字、张量与内存:模型在计算机里长什么样 · Numbers, Tensors & Memory

Generative AI · 预备知识 1。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

这是一篇前置讲,不对应原课的某一次课。原课第 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

  1. 会把整数写成二进制补码;知道各种整数类型的范围,以及溢出时会发生什么。
  2. 能拆开一个浮点数(符号、指数、尾数),说出 FP32、FP16、BF16、FP8 各自的范围和精度,并解释为什么 BF16 成了训练的主流格式。
  3. 理解浮点运算的几个陷阱:舍入、溢出 / 下溢、加法不满足结合律、低精度累加停滞、灾难性抵消。
  4. 说出混合精度训练的四个要点:FP32 主权重、损失缩放、高精度累加、数值稳定的 softmax;知道混合精度到底省了什么。
  5. 理解张量的存储 + 步长模型,分清视图和复制,会读广播规则。
  6. 会给一个 Transformer 算账:参数量、推理显存(权重 + KV 缓存)、训练显存(每个参数 16 字节)、激活值。
  7. 知道”搬数据”和”做计算”的速度差异,理解算术强度的直觉(为前置讲 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 无符号整数与补码

nn 个比特能表示 2n2^n 种不同的状态。用它们表示非负整数(无符号整数,unsigned)最自然:比特串就是这个数的二进制写法,范围是 [0,2n−1][0, 2^n - 1]。

负数怎么办? 现代计算机几乎都用二进制补码(two’s complement):非负数照常写;负数 −x-x 存成 2n−x2^n - x。01 第 1 部分:

整数8 位补码当作无符号数读
0000000000
5000001015
12701111111127
−111111111255
−211111110254
−12810000000128

规律:最高位是 1 就是负数;同一串比特当作无符号数读就是另一个值。补码的好处是加法电路不用区分正负:5+(−5)5 + (-5) 在 8 位下是 00000101 + 11111011 = 100000000,第 9 位溢出被丢掉,剩下的正好是 0。

1.3 范围与溢出

类型位数范围
int8 / uint88[−128, 127] / [0, 255]
int16 / uint1616[−32,768, 32,767] / [0, 65,535]
int32 / uint3232[−2,147,483,648, 2,147,483,647] / [0, 4,294,967,295]
int6464约 ±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^61 MiB = 2^204.9%
1 GB = 10^91 GiB = 2^307.4%
1 TB = 10^121 TiB = 2^4010.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 讲农场语言56uint8
GPT-250,257uint16(最大 65,535)
Llama 232,000uint16
Llama 3128,256uint32

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×10236.02 \times 10^{23} 由三部分组成——符号(正)、尾数(6.02,决定精度)、指数(23,决定大小)。

计算机用的是二进制科学计数法:

x=(−1)s×2e×(1.m1m2…mM)2x = (-1)^{s} \times 2^{e} \times (1.m_1 m_2 \dots m_M)_2

“浮点”(floating point)的意思是小数点的位置随指数”浮动”:同样 MM 位尾数,可以表示 1.01012×2101.0101_2 \times 2^{10}(很大)也可以表示 1.01012×2−101.0101_2 \times 2^{-10}(很小),相对精度大致不变。

2.2 三段比特

一个浮点数的比特分成三段:

| 符号 s (1 位) | 指数字段 E 位 | 尾数字段 M 位 |
  • 符号:0 正 1 负。
  • 指数字段:存的是”实际指数 + 偏置”,偏置 bias =2E−1−1= 2^{E-1} - 1(FP32 是 127)。这样指数字段是一个非负整数,比较大小更方便。
  • 尾数字段:二进制小数点后面的 MM 位。正规数(normal)的最高位一定是 1(就像科学计数法里尾数总是写成 1.xxx),所以这个 1 不用存,叫”隐含位”。MM 位尾数实际有 M+1M+1 位精度。

于是正规数的值是:

x=(−1)s×2 指数字段−bias×(1+尾数字段2M)x = (-1)^{s} \times 2^{\,\text{指数字段} - \text{bias}} \times \Big(1 + \frac{\text{尾数字段}}{2^{M}}\Big)

例子:把 π 存进五种格式(02 第 1 部分):

格式符号 | 指数 | 尾数还原的值相对误差
FP320 | 10000000 | 100100100001111110110113.1415927412.8e-08
FP160 | 10000 | 10010010003.1406253.1e-04
BF160 | 10000000 | 10010013.1406253.1e-04
FP8-E4M30 | 1000 | 1013.253.5e-02
FP8-E5M20 | 10000 | 1034.5e-02

以 BF16 为例:指数字段 100000002=12810000000_2 = 128,减去偏置 127 得实际指数 1;尾数 10010012=731001001_2 = 73;值 =21×(1+73/128)=3.140625= 2^1 \times (1 + 73/128) = 3.140625。π 在 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≈十进制有效位
FP32328233.4e381.2e-381.2e-077.2
FP1616510655046.1e-059.8e-043.3
BF1616873.4e381.2e-387.8e-032.4
FP8-E4M38434480.01560.1251.2
FP8-E5M2852573446.1e-050.250.9

几个公式(EE 位指数、MM 位尾数、偏置 b=2E−1−1b = 2^{E-1}-1):

  • 机器 ε(machine epsilon)= 1 和”比 1 大的下一个可表示数”之间的距离 = 2−M2^{-M}。它刻画相对精度:任何正规数存进去后,相对误差最多是 ε/2\varepsilon/2。
  • 最大值:IEEE 格式把”指数字段全 1”留给 inf 和 NaN,所以最大的实际指数是 2E−2−b2^E - 2 - b,最大尾数全 1:max⁡=22E−2−b(2−2−M)\max = 2^{2^E - 2 - b}(2 - 2^{-M})。FP16:215×(2−2−10)=655042^{15} \times (2 - 2^{-10}) = 65504。
  • E4M3 是个例外:它不表示 inf,只把”指数全 1 且尾数全 1”留给 NaN,于是能多用一段指数,最大值 28×1.75=4482^8 \times 1.75 = 448。

怎样读这张表:指数位决定范围,尾数位决定精度。

  • 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 的间距
11.2e-070.0009770.0078
10006.1e-050.54
10^60.0625溢出4096

间距随数值大小成比例增长:数值每翻一倍,间距也翻一倍。所以相对精度大致不变,绝对精度却随数值变大而变差。两个直接的后果:

  1. 0.1 + 0.2 ≠ 0.3。0.1 在二进制里是无限循环小数(就像 1/3 在十进制里),只能存一个近似值:0.1 存成 FP32 后是 0.100000001490116…。比较浮点数时永远用”差的绝对值小于某个容差”(math.isclose、torch.allclose),而不是 ==。
  2. 整数也会丢。FP16 能精确表示的最大连续整数是 2048(2049 存进去变成 2048),BF16 是 256(257 → 256),FP32 是 224=16,777,2162^{24} = 16{,}777{,}216。所以”把位置编号、计数器存成 BF16”一定会出错。

2.5 特殊值:溢出、下溢、次正规数、inf 与 NaN

02 第 4 部分:

原始值存进变成类别
70000FP16inf溢出
70000BF1670144正规数(只是精度粗)
1e-8FP160下溢
3e-8FP165.96e-08次正规数
1e-8BF161.00117e-08正规数
500FP8-E4M3448饱和到最大值
1e6FP8-E5M2inf溢出
  • 溢出(overflow):超过最大值就变成 inf。FP16 里 e11=59,874e^{11} = 59{,}874 还能存,e12=162,755e^{12} = 162{,}755 就是 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,值 =21−b×(尾数/2M)= 2^{1-b} \times (\text{尾数}/2^M),让数可以”逐渐”降到 0,而不是从最小正规数直接跳到 0。代价是精度越来越差(3e-8 存成了 5.96e-8,误差 100%)。
  • FP8 的越界处理不统一:本机 PyTorch 把超出 E4M3 范围的 500 饱和成 448(这种格式没有 inf),而 E5M2 的 1e6 变成 inf。不同框架和硬件的约定不完全一样,所以实际使用时会先用缩放因子把数值缩到范围内。

2.6 浮点运算的三个陷阱

陷阱一:加法不满足结合律。 在 FP32 里,(108+1)−108=0(10^8 + 1) - 10^8 = 0,而 (108−108)+1=1(10^8 - 10^8) + 1 = 1。原因:10810^8 附近 FP32 的间距是 8,加 1 被舍入掉了。同样三个数,换个加法顺序结果就不同。后果之一是:并行计算的结果可能和串行不同(多线程、多 GPU 的求和顺序不确定),这是深度学习里”同样的代码两次运行结果有微小差异”的根源之一。

陷阱二:低精度累加会停滞。 把 10,000 个 0.0001 逐个加起来(精确答案 1.0):

格式逐个累加torch.sum
FP321.000051
FP160.251
BF160.031251

FP16 加到 0.25 就停住了:0.25 附近 FP16 的间距是 2−12≈0.0002442^{-12} \approx 0.000244,0.0001 不到间距的一半,加上去被舍入掉——“加了等于没加”。BF16 的尾数更少,停得更早。torch.sum 没有这个问题:它不是从左往右逐个累加,而是分块求和,并且在 CPU 上对 FP16/BF16 输入用 FP32 做中间累加,最后才舍入回低精度。这引出低精度计算的通用原则:存储可以低精度,累加要用高精度(§3.4)。

陷阱三:灾难性抵消。 两个非常接近的数相减,有效数字会大量抵消。经典例子是方差的”一遍算法” Var(x)=E[x2]−E[x]2\mathrm{Var}(x) = \mathbb{E}[x^2] - \mathbb{E}[x]^2。对均值 10000、方差约 1 的 10 万个数(FP32):

算法方差
E[x2]−E[x]2\mathbb{E}[x^2] - \mathbb{E}[x]^2(一遍)−8.000000
E[(x−E[x])2]\mathbb{E}[(x - \mathbb{E}[x])^2](两遍)1.000258
FP64 参照1.000257

E[x2]≈108\mathbb{E}[x^2] \approx 10^8,FP32 在 10810^8 附近的间距是 8;两个约 10810^8 的数相减,真正的差(约 1)比舍入误差还小,结果完全不可信——方差居然算成了负数。先减均值再平方就没有这个问题。LayerNorm、BatchNorm 的实现都用稳定的算法(两遍或 Welford 在线算法),并且通常在 FP32 里算统计量,哪怕输入是 BF16。


3. 混合精度训练 · Mixed-precision training

3.1 为什么要用低精度

低精度的好处有三个:

  1. 省内存:BF16 每个数 2 字节,是 FP32 的一半。
  2. 省带宽:同样的数据量,读写的字节数减半;很多运算的瓶颈恰恰是读写内存(§6)。
  3. 算得快:GPU 的张量核心(Tensor Core)专为低精度矩阵乘法设计。第 1 讲的 slides 引用 PyTorch 文档说,A100 上 float16 矩阵乘法的峰值性能是 float32 的 16 倍。

但 §2 已经看到,低精度会带来溢出、下溢、舍入和累加停滞。混合精度训练(mixed-precision training,Micikevicius et al., 2018)的思想是:大部分计算用低精度,少数对精度敏感的地方用 FP32。它有四个要点。

3.2 要点一:FP32 主权重

03 第 1 部分:从 w=1.0w = 1.0 开始,做 1000 次 w←w+0.001w \leftarrow w + 0.001(正确答案 2.0):

方案最终权重
权重直接存成 FP322.000047
权重直接存成 FP161.976562
权重直接存成 BF161.000000
BF16 计算 + FP32 主权重2.000000(主权重 2.000047)

BF16 在 1.0 附近的间距是 2−7≈0.00782^{-7} \approx 0.0078,更新量 0.001 不到间距的一半,每次都被舍入回原值,权重原地踏步。训练后期”学习率 × 梯度”常常比权重本身小 4 到 6 个数量级,这种情况非常普遍。

解决办法:保留一份 FP32 主权重(master weights),优化器的更新加在主权重上;每一步前向和反向时,再把主权重转换成一份 BF16 副本来做计算。小更新在 FP32 里慢慢累积,累积到足够大时,BF16 副本自然就变了。

3.3 要点二:损失缩放(FP16 才需要)

03 第 2 部分模拟 10 万个梯度,数量级在 1e-10 到 1e-2 之间均匀分布(对数尺度上):

缩放因子 SFP16 下溢成 0 的比例溢出成 inf 的比例非零梯度的相对误差(中位数)
10.3080.0006.5e-04
2^80.0080.0002.8e-04
2^160.0000.0001.9e-04
2^240.0000.0511.7e-04

不缩放时,30.8% 的梯度在 FP16 里直接变成 0。损失缩放(loss scaling)的做法是:反向传播之前把损失乘以一个大数 SS。由链式法则,所有梯度也都被乘以 SS,于是从下溢区被”抬”进了可表示的范围;更新参数之前,再在 FP32 里除以 SS。S=216S = 2^{16} 时下溢比例降到 0,也没有溢出。

SS 太大又会溢出(2242^{24} 时 5.1% 变成 inf)。实践中用动态损失缩放:从一个大的 SS 开始,一旦某一步出现 inf / NaN,就跳过这一步并把 SS 减半;连续很多步没出问题,再把 SS 加倍。PyTorch 的 torch.amp.GradScaler 做的就是这件事。

BF16 不需要损失缩放:同一批梯度存成 BF16,一个都没下溢——它的指数位和 FP32 一样多。这是 BF16 在训练中受欢迎的又一个原因。

3.4 要点三:高精度累加

03 第 3 部分:两个随机的 FP16 向量做点积,用 FP16 累加器和 FP32 累加器分别计算(误差以”乘积的典型大小”为单位):

长度 nFP16 累加的误差FP32 累加的误差
2561.5e-033.2e-07
4,0962.8e-031.5e-07
65,5364.1e-033.9e-06

FP16 累加的误差随长度增长(§2.6 的累加停滞是它的极端情况)。矩阵乘法 C=ABC = AB 的每个元素都是一个长度为 kk 的点积,kk 在大模型里是几千到几万。所以张量核心的规则是:输入用 BF16 / FP16 / FP8,乘积在 FP32 里累加,最后再舍入输出。归约类运算(求和、求均值、LayerNorm 的统计量、softmax 的分母)也都应该在 FP32 里做。

3.5 要点四:数值稳定的 softmax

03 第 4 部分:logits = [10, 11, 12](FP16)。e12≈162,755e^{12} \approx 162{,}755 超过了 FP16 的最大值,变成 inf,于是朴素的 softmax 得到 [0, 0, NaN]。由于

softmax(b)i=ebi∑jebj=ebi−c∑jebj−c对任意常数 c 成立,\mathrm{softmax}(b)_i = \frac{e^{b_i}}{\sum_j e^{b_j}} = \frac{e^{b_i - c}}{\sum_j e^{b_j - c}} \quad \text{对任意常数 } c \text{ 成立,}

取 c=max⁡jbjc = \max_j b_j,最大的指数变成 e0=1e^0 = 1,永远不会溢出,结果 [0.0901, 0.2448, 0.6655](FP64 参照 [0.0900, 0.2447, 0.6652])。同样的技巧用在 log⁡∑jebj\log \sum_j e^{b_j}(log-sum-exp)上,就是第 1 讲交叉熵模块的稳定写法;第 18 讲的 FlashAttention 要把它推广成”在线 softmax”,一块一块地处理注意力分数。

3.6 混合精度到底省了什么?

03 第 5 部分:用 Adam 训练时每个参数要存什么——

方案每个参数存什么(字节)合计
全 FP32权重 4 + 梯度 4 + Adam 一阶矩 4 + 二阶矩 416
混合精度BF16 权重 2 + BF16 梯度 2 + FP32 主权重 4 + 一阶矩 4 + 二阶矩 416

一个反直觉的结论:混合精度并不省参数和优化器状态的内存。多存的那份 BF16 权重抵消了梯度变小省下的空间(这正是 ZeRO 论文里”每个参数 16 字节”的来历)。它真正省的是:

  1. 激活值:以 BF16 保存,大约减半(§5.5 会实测,并看到一个意外);
  2. 时间: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)。元素 x[i,j]x[i, j] 在存储中的位置是

位置=偏移+i×步长0+j×步长1\text{位置} = \text{偏移} + i \times \text{步长}_0 + j \times \text{步长}_1

04 第 1 部分:x = torch.arange(12).reshape(3, 4) 的存储就是 0, 1, …, 11 按顺序排列,步长是 (4, 1)——下一行要跳过 4 个元素,下一列只跳 1 个。x[2,1]x[2, 1] 在位置 2×4+1×1=92 \times 4 + 1 \times 1 = 9。

张量形状步长连续?存储偏移
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;
  2. 每一维要么相等,要么其中一个是 1——是 1 的那个被”拉伸”到另一个的长度;
  3. 否则报错。
形状 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 的导数是 σ(x)(1−σ(x))\sigma(x)(1 - \sigma(x)),autograd 在前向时保存了输出 aa 留给反向用;mul_ 原地改掉了 aa,反向时就会算错。PyTorch 给每个张量维护一个”版本号”,发现被保存的张量版本变了就报错。写成 b = torch.sigmoid(x) * 2(产生新张量)就没问题。

这个例子说明了激活值为什么占内存:反向需要的中间结果必须原封不动地保留到反向传播用完为止。§5.5 会测量它们有多大。


5. 给模型算账 · Memory accounting

5.1 参数量公式

第 2、4 讲会讲清 Transformer 的每个部件;这里只需要知道一个仅解码器的 Transformer 由 LL 层组成,每层有一个注意力子层和一个前馈(MLP)子层,外加输入的嵌入表和输出层。设隐藏维度 dd、前馈层中间维度 dffd_{\text{ff}}、词表大小 VV:

部件参数量
注意力:Q、K、V、O 四个投影普通多头注意力:4d24d^2;GQA:2d2+2d⋅dkv2d^2 + 2 d \cdot d_{kv}(dkvd_{kv} = KV 头数 × 头维度)
前馈层普通 MLP:2d⋅dff2 d \cdot d_{\text{ff}};SwiGLU(多一个门控矩阵):3d⋅dff3 d \cdot d_{\text{ff}}
归一化层每层 2 个,每个 dd(RMSNorm)或 2d2d(LayerNorm)
嵌入 + 输出层VdV d(共享时)或 2Vd2 V d

GPT 式的经典配置 dff=4dd_{\text{ff}} = 4d:每层 4d2+8d2=12d24d^2 + 8d^2 = 12d^2,全模型约 12Ld2+Vd12 L d^2 + V d。这个"12Ld212Ld^2"值得记住:它让你一眼估出一个模型的大小。

怎样确认公式没写错? 最可靠的办法是真建一个小模型,让框架数。05 第 1 部分按配置用 nn.Linear、nn.LayerNorm / nn.RMSNorm 搭出所有参数(不需要前向),sum(p.numel()) 和公式逐个相等:小 GPT 式配置 106,496 个,小 Llama 式配置 143,040 个。漏一个偏置、多算一个归一化层,这里立刻就能发现。

5.2 著名模型的参数都花在哪里

模型公式算出公开的数字注意力前馈层嵌入 + 输出层
GPT-3 175B(L=96L=96, d=12288d=12288)174.58B175B33.2%66.4%0.4%
Llama-3 8B(L=32L=32, d=4096d=4096, 8 个 KV 头)8.03B8.03B16.7%70.2%13.1%
Llama-3 70B(L=80L=80, d=8192d=8192, 8 个 KV 头)70.55B70.6B17.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 8B15.0 GiB7.5 GiB3.7 GiB
Llama-3 70B131.4 GiB65.7 GiB32.9 GiB

8B 模型用 BF16,一块 24 GB 的消费级显卡放得下;70B 需要两块 80 GB 的卡,或者量化到 INT4(第 18 讲)。

生成文字时,还要存 KV 缓存:注意力在生成第 tt 个 token 时要用到前面所有 token 的键(K)和值(V),把它们存下来就不必每一步重算(第 3 讲)。它的大小是

KV 缓存=2×L×nkv×dhead×序列长度×批大小×每个数的字节数\text{KV 缓存} = 2 \times L \times n_{kv} \times d_{\text{head}} \times \text{序列长度} \times \text{批大小} \times \text{每个数的字节数}
模型(BF16,批大小 1)每个 token8K 上下文128K 上下文
Llama-3 8B(GQA,8 个 KV 头)128 KiB1.0 GiB16.0 GiB
Llama-3 8B(假如 32 个头都有 KV)512 KiB4.0 GiB64.0 GiB
Llama-3 70B(GQA,8 个 KV 头)320 KiB2.5 GiB40.0 GiB
Llama-3 70B(假如 64 个头都有 KV)2.5 MiB20.0 GiB320.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 8B119.7 GiB1.5
Llama-3 70B1.0 TiB13.1
GPT-3 175B2.5 TiB32.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)手算 4B(din+dh+类别数)+8B4B(d_{in} + d_h + \text{类别数}) + 8B
32393,476393,472
64786,948786,944
1281,573,8921,573,888

实测和手算只差一个与批大小无关的 4 字节标量。要保存的是:第一个线性层的输入 xx(算权重梯度要用)、ReLU 的输出(同时被 ReLU 的反向和第二个线性层的反向用到,只存一份)、softmax 的对数概率,外加 int64 的标签。

BF16 autocast 下的意外。 在 torch.autocast(device_type="cpu", dtype=torch.bfloat16) 里重测,B=128 的总数反而从 1.57 MB 涨到了 3.02 MB。用两个批大小拆开”每样本部分”和”固定部分”才看清:

每个样本与批大小无关的固定部分
FP3212,296 字节4 字节
BF16 autocast7,176 字节2,097,156 字节

每个样本的激活值确实减少了(线性层的输入和 ReLU 输出以 BF16 保存;交叉熵前的 logits 被 autocast 留在 FP32,所以不是正好减半);多出来的固定部分正好是第二个线性层权重的 BF16 副本(2048 × 512 × 2 = 2,097,152 字节)——autocast 每一步都把 FP32 权重临时转成 BF16 副本,反向要用到的那份副本也被保存了。批大小或序列长度一大,每样本省下的部分就占了主导,这才是”混合精度省激活值”的真实含义。(第一个线性层的权重副本不需要保存:输入 xx 不需要梯度,反向用不到它。)

第 1 讲的 RNN-LM:每个 token 的激活值基本是常数(886–906 字节),总量 ∝ 批大小 × 序列长度。

大 Transformer。 Korthikanti et al. (2022) 估算,一个标准 Transformer 层(16 位激活、不做重计算)要保存约

s⋅b⋅h⋅(34+5ash) 字节s \cdot b \cdot h \cdot \Big(34 + \frac{5 a s}{h}\Big) \text{ 字节}

(ss 序列长度、bb 批大小、hh 隐藏维度、aa 注意力头数)。“34” 来自线性层的输入、归一化、激活函数和 dropout 掩码;"5as/h5as/h" 来自注意力分数矩阵,随 s2s^2 增长。

模型序列长度、批大小每层全部层注意力分数矩阵的占比
GPT-2 small(124M)s=1024, b=8684 MiB8.0 GiB70%
GPT-3 175Bs=2048, b=12.7 GiB256.5 GiB70%

GPT-3 只处理一条 2048 token 的序列,激活值就要 256.5 GiB,接近它全部 BF16 权重(326 GiB);而实际训练每步要处理成百上千条序列。两个常用的解法:激活重计算(activation checkpointing,只存每层的输入,反向时重算其余部分,用大约三分之一的额外计算换大量内存,第 17 讲);FlashAttention(根本不把 s×ss \times s 的注意力矩阵存下来,第 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)——每读写一个字节能做多少次浮点运算:

  • 逐元素加法 ci=ai+bic_i = a_i + b_i:每个元素 1 次运算,读写 12 个字节(读两个 FP32、写一个),强度 1/12≈0.081/12 \approx 0.08 FLOP/字节。它的速度完全被内存带宽卡死:实测 120 GB/s,几乎等于纯复制的速度。
  • 矩阵乘法 C=ABC = AB(m×mm \times m):2m32m^3 次运算,读写约 12m212m^2 字节,强度 m/6≈171m/6 \approx 171 FLOP/字节(m=1024m = 1024)。每个数被重复使用 mm 次,瓶颈变成了计算本身。

算术强度低的运算叫访存受限(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

  1. 整数用二进制补码存储,超出范围会回绕而不是报错;token id 的类型由词表大小决定(GPT-2 用 uint16,Llama 3 要 32 位)。
  2. 浮点数 = 符号 + 指数 + 尾数:指数位决定范围,尾数位决定精度。FP16 精度较好但范围小(最大 65504);BF16 范围和 FP32 一样、精度只有 2–3 位十进制有效数字;FP8 要配合缩放因子使用。
  3. 可表示的数是离散的格点,间距随数值成比例增长:0.1 + 0.2 ≠ 0.3,FP16 里 2049 → 2048。超出范围变成 inf,太小变成 0;一个 NaN 会传染给后面所有的计算。
  4. 浮点运算的三个陷阱:加法不满足结合律;低精度累加会停滞(10,000 个 0.0001 在 FP16 里加到 0.25 就停了);灾难性抵消(一遍法算方差得到 −8)。原则:存储可以低精度,累加要用高精度。
  5. 混合精度训练:FP32 主权重(BF16 权重上的小更新会被吞掉)、损失缩放(FP16 才需要;不缩放时 30% 的梯度下溢)、FP32 累加、减最大值的 softmax。它不省参数和优化器状态(仍是 16 字节/参数),省的是激活值和时间。
  6. 张量 = 存储 + 步长。转置、切片是视图(共享内存);view 不复制,reshape 可能复制;小切片会拖住整块存储;广播靠步长 0 实现,不占内存。
  7. 算账:GPT 式模型约 12Ld212Ld^2 个参数,大头在前馈层;推理 = 权重(BF16 2 字节/参数)+ KV 缓存(随上下文线性增长,GQA 省 4–8 倍);训练 = 16 字节/参数 + 激活值(∝ 批大小 × 序列长度,注意力部分 ∝ 序列长度²)。
  8. 搬数据常常比算数据更慢:算术强度决定一个运算是访存受限还是计算受限;逐元素运算和矩阵乘法的效率可以差几百倍。

8. 常见误区 · Common pitfalls

  1. “BF16 比 FP16 更精确,因为它更新。” 恰好相反:BF16 的尾数更少,精度更低;它的优势是范围大。
  2. “用了混合精度,显存就减半了。” 参数 + 梯度 + Adam 状态仍是 16 字节/参数;省的主要是激活值,而且 autocast 还会多存权重的低精度副本。
  3. “浮点数可以用 == 比较。” 用容差比较;两次运行、两种求和顺序的结果都可能有微小差别。
  4. “梯度检查、方差这种小事用 FP16 也行。” 有限差分和方差恰恰是对精度最敏感的计算,要用 FP64 / FP32 和稳定的算法。
  5. “reshape 和 view 一样。” reshape 可能悄悄复制;view 保证不复制,做不到就报错。
  6. “del x 就释放了 x 的内存。” 只要还有视图或计算图引用那块存储,它就不会释放;记日志时用 .item()。
  7. “(3, 4) 的矩阵加上长度为 3 的向量,就是给每行加一个数。” 广播从最后一维对齐,这会报错;要写成 (3, 1)。
  8. “推理显存就是权重的大小。” 长上下文、大批量时,KV 缓存可以和权重一样大甚至更大。
  9. “GPU 的算力越大,所有运算都越快。” 访存受限的运算(逐元素运算、批大小为 1 的解码)只看内存带宽,算力再大也用不上。
  10. “GB 和 GiB 差不多。” 到 TB 级别差 10%,足以决定放不放得下。

9. 练习 · Exercises

练习 1(补码与溢出)。 (a) 写出 −37 的 8 位补码。(b) int8 里 100 + 100 等于多少?(c) 为什么 int8 的最小值是 −128 而不是 −127?

参考答案

(a) 256−37=219=110110112256 - 37 = 219 = 11011011_2。可以用 num_lab.int_bits(-37, 8) 验证。

(b) 200 超出了 127,回绕成 200−256=−56200 - 256 = -56。

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

  • 1.5=20×1.121.5 = 2^0 \times 1.1_2:指数字段 0+15=15=011110 + 15 = 15 = 01111,尾数 10000000001000000000。比特:0 | 01111 | 1000000000。
  • −2.0=−21×1.0-2.0 = -2^1 \times 1.0:符号 1,指数字段 16=1000016 = 10000,尾数全 0。比特:1 | 10000 | 0000000000。
  • 0.1=2−4×1.60.1 = 2^{-4} \times 1.6:指数字段 −4+15=11=01011-4 + 15 = 11 = 01011;尾数是 0.60.6 的二进制小数 0.1001100110011…20.1001100110011\dots_2 截取 10 位并舍入,得 10011001102=6141001100110_2 = 614。存进去的值 =2−4×(1+614/1024)=0.0999755859375= 2^{-4} \times (1 + 614/1024) = 0.0999755859375。

验证:num_lab.decompose(0.1, "FP16")(本机输出 ('0', '01011', '1001100110')、0.0999755859375)。

练习 3(设计一种格式)。 假设有一种 8 位格式 E3M4(3 位指数、4 位尾数,按 IEEE 的规则保留 inf/NaN)。它的偏置、最大值、最小正规数、ε 各是多少?它适合存什么?

参考答案

偏置 22−1=32^{2} - 1 = 3。最大实际指数 23−2−3=32^3 - 2 - 3 = 3,最大值 23×(2−2−4)=15.52^3 \times (2 - 2^{-4}) = 15.5;最小正规数 21−3=0.252^{1-3} = 0.25;ε =2−4=0.0625= 2^{-4} = 0.0625。范围只有 [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 2×32×8×128×2=131,0722 \times 32 \times 8 \times 128 \times 2 = 131{,}072 字节 = 128 KiB。128 KiB×32768×16=64128 \text{ KiB} \times 32768 \times 16 = 64 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 字节/参数,109×16/230≈14.910^9 \times 16 / 2^{30} \approx 14.9 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(损失缩放的范围)。 假设所有梯度都在 [10−9,10−3][10^{-9}, 10^{-3}] 之间。要让它们乘以 SS 后在 FP16 里既不下溢到次正规区(最小正规数约 6.1×10−56.1 \times 10^{-5})也不溢出(65504),SS 应该在什么范围?2 的哪个幂次合适?

参考答案

下限:10−9S≥6.1×10−510^{-9} S \ge 6.1 \times 10^{-5},即 S≥6.1×104S \ge 6.1 \times 10^4。上限:10−3S≤6550410^{-3} S \le 65504,即 S≤6.55×107S \le 6.55 \times 10^7。2 的幂次里 216=655362^{16} = 65536 到 225≈3.4×1072^{25} \approx 3.4 \times 10^7 都可以;PyTorch 的 GradScaler 默认的初始缩放因子正是 2162^{16}。用 2 的幂次的好处是:乘以 2k2^k 只改变指数、不改变尾数,缩放和反缩放本身不引入舍入误差。

练习 8(灾难性抵消)。 用 FP32 计算 f(x)=(1−cos⁡x)/x2f(x) = (1 - \cos x) / x^2 在 x=10−4x = 10^{-4} 处的值(精确值约 0.5)。为什么会错?怎样改写?

参考答案

本机运行:(np.float32(1) - np.cos(np.float32(1e-4))) / np.float32(1e-4)**2 得到 0.0;x=10−3x = 10^{-3} 时得到 0.4768(误差约 5%)。原因:cos⁡(10−4)≈1−5×10−9\cos(10^{-4}) \approx 1 - 5 \times 10^{-9},而 FP32 在 1 附近的间距约 6×10−86 \times 10^{-8},于是 cos⁡x\cos x 被舍入成 1,1−cos⁡x=01 - \cos x = 0。

改写:用 1−cos⁡x=2sin⁡2(x/2)1 - \cos x = 2\sin^2(x/2),f(x)=2sin⁡2(x/2)/x2f(x) = 2\sin^2(x/2)/x^2,FP32 下得到 0.5000000596。教训:先用数学变形避免两个接近的数相减,而不是寄希望于更高的精度。log-sum-exp、稳定的方差、torch.log1p(算 log⁡(1+x)\log(1+x))都是同一个思路。

练习 9(算术强度与解码)。 生成文字时,如果批大小为 1,每一步的主要计算是”一个 d×dd \times d 的权重矩阵乘以一个长度为 dd 的向量”(矩阵-向量乘法)。(a) 它的算术强度是多少(权重用 BF16)?(b) 这对生成速度意味着什么?(c) 批大小变成 BB 时呢?

参考答案

(a) 运算 2d22d^2 次;读权重 2d22d^2 字节(向量很小,可以忽略),强度约 1 FLOP/字节。

(b) 远低于 GPU 的”平衡点”(H100 约每字节几百次运算),所以批大小为 1 的解码是访存受限的:速度由”每秒能把权重从显存读多少遍”决定。粗算:8B 模型 BF16 权重 15 GiB,H100 带宽约 3.35 TB/s,每秒最多读约 200 遍,也就是每秒最多生成约 200 个 token,无论算力多大。

(c) 批大小 BB 时,同一份权重被 BB 个向量复用,强度约 BB 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 把这些工具从直觉讲起,每一个都用代码采样验证。

← 返回课程目录