训练的账本:显存、FLOPs 与 MFU

Llama 3 8B 只有 80 亿参数,可用 BF16 混合精度加 Adam 训练它,光权重、梯度和优化器状态就要约 128 GB,一张 80 GB 的 H100 放不下,激活值还没算。数据并行、ZeRO、张量并行、流水线并行,本质上都在回答同一个问题:一步训练要花多少显存、多少计算、多少通信,怎么在这三者之间做交换。哪怕只有一张卡,也有三本账要算:

1. 三个约束:显存、计算、通信

训练一个大模型,始终在和三个约束打交道:

  1. 显存是硬约束。一步训练放不进显存,就根本跑不起来(OOM,out of memory)。
  2. 计算效率:希望硬件绝大部分时间在做计算,而不是在搬数据、等别的卡。
  3. 通信开销:卡与卡之间传数据时,计算单元常常在空等。要尽量用快的链路(机内)而少用慢的链路(机间),并且让通信和计算重叠。

几乎每一种技术都是在这三者之间做交换:激活重计算用计算换显存;张量并行用通信换显存;梯度累积用时间换显存。单卡上只有前两个约束;通信要到多卡才出现,量化它的工具是 Roofline。

2. 一步训练,和以 token 计的 batch size

单卡上的一步训练(一个 step)有三个阶段:

  1. 前向(forward):输入穿过模型,得到输出和 loss;
  2. 反向(backward):从 loss 出发用链式法则算出每个参数的梯度;
  3. 优化器更新(optimizer step):用梯度更新参数,例如 Adam。

Batch size(记作 \(bs\))是一步里用多少条样本估计梯度。它同时影响收敛和吞吐:太小,梯度噪声大;太大,每个 token 被利用得不充分,同样的 token 数换来的 loss 下降变少。好在最终效果对 batch size 在最优值附近并不敏感,所以工程上有不少调整余地。有的训练还会在过程中逐步增大 batch:DeepSeek-V3 在前 469B token 里把 batch 从 3,072 条序列逐渐增大到 15,360 条,之后保持不变;Llama 3 405B 先用 4M token 的 batch(序列长 4,096),训练 252M token 后翻倍到 8M(序列长 8,192),训练到 2.87T token 时再翻倍到 16M。

预训练圈子里习惯用 token 数 报告 batch size,因为它和序列长度无关:

$$bst = bs \times seq$$

这里 \(seq\) 是序列长度。近年 LLM 预训练的 batch 大致在每步 4M 到 60M token 之间:Llama 1 用约 4M token 的 batch 训练了 1.4T token,DeepSeek-V3 最终用约 63M token(15,360 条 × 4,096)的 batch 训练了 14.8T token。问题马上就来了:一张 80 GB 的 H100 显然装不下 4M token 的激活值。为了搞清楚怎么办,先看显存到底花在哪。

3. 显存里住着什么

训练时显存里主要有四样东西:

  1. 模型权重(weights / parameters);
  2. 梯度(gradients),和权重一一对应,形状相同;
  3. 优化器状态(optimizer states),例如 Adam 的一阶矩 \(m\) 和二阶矩 \(v\),也和权重形状相同;
  4. 激活值(activations):前向时产生、反向时要用到的中间张量。

前三样只和模型大小有关,是「静态」的;激活值和输入有关,随 batch size 和序列长度变化。此外还有一些难以精确计算的杂项:CUDA 上下文和内核本身要占 1–2 GB(可以执行一句 torch.ones((1, 1)).to("cuda"),再用 nvidia-smi 看看占了多少);中间缓冲区和显存碎片也会吃掉一些。下面的估算都忽略这些常数项。

一步之内显存怎么变化

显存占用并不是一个静态数字,在一步之内起伏很大。用 PyTorch profiler 看一步训练,形状通常是:前向时激活值快速堆高;反向开始后,梯度一层层生成,同时用过的激活值一层层释放;最后优化器用全部梯度更新参数和优化器状态,然后进入下一步。

还有一个容易踩的坑:第一步能跑,第二步才 OOM。原因是 Adam 的状态在第一次调用 optimizer.step() 时才分配(PyTorch 是惰性创建的),所以从第二步开始,优化器状态和激活值同时在显存里,峰值更高。另外,第一步的显存曲线形状也和后面不同:PyTorch 的缓存分配器在第一步里做了大量预分配,之后的步就不用再找空闲块了。下面的动画把前两步画了出来。

交互演示 · 一步训练的显存时间线(示意)

一个 4 层的模型,每层参数量相同,按 BF16 混合精度 + Adam 计(每个参数:权重 2 + FP32 主权重 4 = 6 份、梯度 2 份、优化器状态 8 份);每层激活值的大小和虚线表示的显存上限都是示意值。

4. 数参数:从公式到 Llama 3

静态显存 = 参数量 × 每个参数的字节数。先数参数。记号:隐藏维度 \(h\),层数 \(L\),词表大小 \(V\),注意力头数 \(a\),每头维度 \(d_h = h/a\),MLP 中间维度 \(F\)。

经典 GPT 结构

先看最经典的 GPT 结构:线性层带偏置,用 LayerNorm,MLP 中间维度是 \(4h\),输出层和输入嵌入共享权重。参数量(不算位置嵌入)是:

$$N = h\,V + L\,(12h^2 + 13h) + 2h$$

逐项拆开就是:

当 \(h\) 大时,\(h^2\) 项占绝对主导,所以常用的近似是 \(N \approx 12Lh^2\)。

Llama 结构:GQA + SwiGLU + RMSNorm

现代模型有三个变化:(1)注意力用 GQA(grouped-query attention),K、V 只有 \(n_{kv}\) 个头,于是 \(W_K, W_V\) 是 \(h \times n_{kv} d_h\);(2)MLP 用带门控的 SwiGLU,有三个矩阵 \(W_\text{gate}, W_\text{up}\in\mathbb{R}^{h\times F}\)、\(W_\text{down}\in\mathbb{R}^{F\times h}\);(3)RMSNorm 只有缩放参数,没有偏置,线性层也不带偏置。于是每层:

$$N_\text{layer} = \underbrace{2h\cdot a d_h + 2h\cdot n_{kv} d_h}_{\text{注意力}} + \underbrace{3hF}_{\text{MLP}} + \underbrace{2h}_{\text{RMSNorm}}$$

整个模型再加上输入嵌入 \(Vh\)、不共享的输出层 \(Vh\)、最后一个 RMSNorm \(h\)。拿 Llama 3 的三个尺寸代进去(三者的词表都是 128,256,都是 \(n_{kv}=8\)、\(d_h = 128\),输出层都不与嵌入共享):

8B70B405B
层数 \(L\)3280126
隐藏维度 \(h\)4,0968,19216,384
MLP 维度 \(F\)14,33628,67253,248
Q 头数 \(a\) / KV 头数32 / 864 / 8128 / 8
每层注意力参数41.9M151.0M570.4M
每层 MLP 参数176.2M704.6M2,617.2M
嵌入 + 输出层1.05B2.10B4.20B
总参数 \(N\)8.03B70.55B405.85B

几个观察:MLP 占每层参数的约 80%(GQA 让注意力更便宜了);8B 模型里光嵌入和输出层就有 1.05B,占 13%,这是大词表在小模型上的代价;到 405B,它们只占 1% 左右。

5. 每个参数几个字节:16 字节规则及其变体

精度决定每个数占几个字节:FP32 是 4 字节,BF16/FP16 是 2 字节,FP8 是 1 字节。下面以最常用的 Adam(或 AdamW)为例,它为每个参数存两个状态:动量 \(m\) 和方差 \(v\)。

全 FP32 训练

$$m_\text{params} = 4N,\quad m_\text{grad} = 4N,\quad m_\text{opt} = (4+4)N \;\Rightarrow\; 16N$$

BF16 混合精度 + FP32 主权重

现在默认的做法是「混合精度」:前向和反向的矩阵乘法用 BF16 算,权重和梯度也以 BF16 存一份;但为了数值稳定,另存一份 FP32 的权重拷贝,叫主权重(master weights),优化器在这份 FP32 拷贝上更新,然后再转成 BF16 给下一步用。Adam 的 \(m, v\) 也用 FP32 存:

$$m_\text{params} = 2N,\quad m_\text{grad} = 2N,\quad m_\text{params,fp32} = 4N,\quad m_\text{opt} = (4+4)N \;\Rightarrow\; 16N$$

有些框架(例如 Nanotron)还会额外保留一份 FP32 的梯度用来累加,因为 BF16 只有 7 位尾数,把很多个小梯度加在一起时会丢精度。这样再加 \(4N\),总共 20 字节/参数。

一个反直觉的事实:混合精度本身并不省权重相关的显存,只是把 16 字节重新分配了一下,带 FP32 梯度累加时甚至更多。它的好处在别处:(1)BF16 矩阵乘法在 GPU 上快得多(H100 上 BF16 Tensor Core 的峰值是 TF32 的 2 倍、FP32 CUDA core 的约 15 倍);(2)激活值用 BF16 存,激活显存减半——而激活值往往是大头。

配置权重梯度主权重优化器合计 / 参数
FP32 + Adam44–816
BF16 混合 + FP32 主权重 + Adam224816
同上,梯度以 FP32 累加22 + 44820
FP32 + 带动量的 SGD44–412
只做推理(BF16)2–––2

后两行是为了对照:优化器状态的大小取决于优化器本身(带动量的 SGD 只存一个状态),而推理只需要权重。用 FP8 训练能进一步压低,但稳定性更难保证(见混合精度与 FP8)。

按 16 和 20 字节/参数,几个常见规模的静态显存是:

参数量16 字节/参数20 字节/参数
1B16 GB20 GB
7B112 GB140 GB
Llama 3 8B(8.03B)128 GB161 GB
70B1,120 GB1,400 GB
405B6,480 GB8,100 GB

结论很直接:7B 往上,光权重、梯度和优化器状态就已经超过单张 H100 的 80 GB。还没算激活值。这就是为什么后面需要 ZeRO/FSDP 把这些状态切到多张卡上。

6. 激活显存:逐项推导

为什么反向需要存激活值

考虑一个线性层 \(Y = XW\),其中 \(X\) 是 \([s, h_\text{in}]\),\(W\) 是 \([h_\text{in}, h_\text{out}]\)。反向时拿到上游梯度 \(\partial \mathcal{L}/\partial Y\),需要算两个东西:

$$\frac{\partial \mathcal{L}}{\partial W} = X^\top \frac{\partial \mathcal{L}}{\partial Y}, \qquad \frac{\partial \mathcal{L}}{\partial X} = \frac{\partial \mathcal{L}}{\partial Y} W^\top$$

第一个式子需要前向时的输入 \(X\)。所以每个矩阵乘法都要把自己的输入留到反向。逐元素的非线性函数也一样:\(y = \sigma(x)\) 的反向 \(\partial\mathcal{L}/\partial x = \sigma'(x)\cdot \partial\mathcal{L}/\partial y\) 需要 \(x\)(或者 \(y\),看函数)。这些在前向时产生、留到反向才用的张量,就是激活值。

GPT 结构的账:\(sbh(34 + 5as/h)\)

知道了「每个矩阵乘法要留输入」这条规则,就可以对一层逐项记账。先算经典的 GPT 结构(带 dropout、朴素注意力),这也是 Korthikanti 等人(2022)给出的经典结果。设序列长 \(s\)、micro-batch 大小 \(b\)(一次前向里的样本数),激活值用 2 字节存,dropout 的掩码用 1 字节存:

位置要存的张量字节
注意力Q/K/V 投影的共同输入\(2sbh\)
\(QK^\top\) 需要 Q 和 K\(4sbh\)
softmax 的输出\(2as^2b\)
softmax 后 dropout 的掩码\(as^2b\)
乘 V:dropout 后的概率和 V\(2as^2b + 2sbh\)
输出投影的输入 + 其后 dropout 掩码\(2sbh + sbh\)
MLP(\(F=4h\))第一个线性层的输入\(2sbh\)
GeLU 的输入、第二个线性层的输入\(8sbh + 8sbh\)
dropout 掩码\(sbh\)
LayerNorm两个 LayerNorm 各存输入\(4sbh\)

注意力块合计 \(11sbh + 5as^2b\),MLP \(19sbh\),LayerNorm \(4sbh\),加起来:

$$m_\text{act/layer} = sbh\left(34 + \frac{5as}{h}\right)\ \text{字节},\qquad m_\text{act} = L\cdot sbh\left(34 + \frac{5as}{h}\right)$$

两点要看清楚:(1)激活显存和 \(b\) 成线性、和 \(s\) 有二次项,来源是 \(s\times s\) 的注意力矩阵,每个头一份;(2)和权重不同,它跟着输入涨。把具体模型代进去就会发现:\(b=1\) 时,短序列的激活值几乎可以忽略,从 2k–4k token 开始变得显著,长序列时远远超过权重相关的部分(第 7 节计算器里的曲线图可以直接看到)。

换成现代模型:没有 dropout,有 FlashAttention、GQA 和 SwiGLU

上面是 2022 年 GPT-3 风格模型的账。今天的 Llama 类模型有四处不同:

残差 x [s,b,h] RMSNorm Q/K/V 输入 W_Q W_K W_V FlashAttention(存 Q、K、V、O、lse) W_O + 残差 2sbh 2sbh Q 2sbh, K+V 4sbh_kv O 2sbh(W_O 的输入) MLP 部分同理:RMSNorm 输入 2sbh、MLP 输入 2sbh, SwiGLU 的 gate、up、SiLU 输出、乘积各 2sbF。 橙点 = 反向需要的张量(BF16 字节数)
Llama 类结构一层的前半部分(注意力块),橙点标出了要留到反向的张量。

加起来,每层(BF16,激活每个数 2 字节):

$$m_\text{act/layer} \approx 2sb\,\big(\underbrace{2h}_{\text{两个 norm 输入}} + \underbrace{h}_{\text{QKV 输入}} + \underbrace{h + 2h_{kv}}_{Q,K,V} + \underbrace{h}_{O} + \underbrace{h}_{\text{MLP 输入}} + \underbrace{4F}_{\text{SwiGLU}}\big) = 2sb\,(6h + 2h_{kv} + 4F)$$

验算一下:把它套回 GPT 结构(\(h_{kv}=h\),GeLU 只有输入和输出两个 \(4h\) 张量,即把 \(4F\) 换成 \(2\cdot 4h\)),得到 \(2sb\cdot 16h = 32sbh\),正好是前面的 34 减去两个 dropout 掩码(\(sbh\) 各一个)。如果用朴素注意力(不用 FlashAttention),还要加上 softmax 的输出 \(2as^2b\)。这本账按不融合内核的 PyTorch 写法来记;把 SwiGLU 写成融合内核的框架,还能再少存一两个 \([s,b,F]\) 张量。

对 Llama 3 8B(\(h=4096, h_{kv}=1024, F=14336\)),括号里是 83,968,每个 token 每层约 168 KB。序列长 8,192、\(b=1\) 时,32 层合计约 44 GB;如果用朴素注意力,光 softmax 矩阵就有 \(32\times 2 \times 32 \times 8192^2\) 字节 ≈ 137 GB。FlashAttention 的意义一目了然。

还有一个容易忽略的大张量:输出层的 logits。它的形状是 \([s, b, V]\),Llama 3 的 \(V = 128{,}256\),如果按 FP32 算交叉熵,一条 8k 序列就要 \(4 \times 8192 \times 128256\) ≈ 4.2 GB。这也是为什么很多框架会把交叉熵分块算或者和输出层融合。

7. 交互:训练显存计算器

选模型(或改配置)、序列长度、micro-batch、精度和重计算方式,看单卡显存的构成。计算器假设整个模型放在一张卡上,没有任何并行切分。

交互演示 · 训练显存计算器
模型
改配置
序列长度
micro-batch
精度
重计算
显卡

激活值按上一节的 Llama 式推导:\(2sb(6h+2h_{kv}+4F)\) 字节/层(BF16;FP32 模式下按 4 字节),朴素注意力另加 softmax 输出 \(2as^2b\);完全重算时每层只存输入 \(2sbh\),再加反向重算一层时的工作集。logits 按 FP32 的 \(4sbV\) 计;「杂项」固定记 1.5 GB,代表 CUDA 上下文等。显存碎片和临时缓冲区没有计入:真实框架里,内核融合会让激活更少,碎片和缓冲区会让总占用更多。

拖一拖就能看到几件事:

8. 激活重计算:拿算力换显存

激活重计算(activation recomputation),也叫梯度检查点(gradient checkpointing)或再物化(rematerialization),思路是:前向时只在少数关键位置保存激活值(叫检查点),其余的丢掉;反向需要时,从最近的检查点重新跑一段前向把它们算回来。本质上是多做一部分前向计算,换更少的显存。

不重算 完全重算 选择性重算 每层保存所有中间张量(含 s×s 注意力矩阵) 每层只存输入;反向时整层前向重跑一遍 存矩阵乘法相关的张量(实心),注意力核心部分(虚线)丢掉,反向时重算
四层的示意。实心 = 保存,虚线 = 丢弃后重算。

完全重算(full)

在每个 Transformer 层的边界设检查点,即只存每层的输入。反向到某层时,先用这个输入把该层前向重跑一遍,再做反向。等于在反向里多做一次完整的前向。按每个参数、每个 token 计,前向 2 FLOPs、重算 2、反向 4,训练 FLOPs 从 \(6ND\) 变成约 \(8ND\)(第 10 节会推导这个 2 : 4),多出三分之一;实际一步的耗时通常也会增加 30–40%。显存方面,激活值从每层几十个 \(sbh\) 降到每层一个 \(2sbh\),再加上反向时一层的工作集。

一步训练里的显存(4 层,大小为示意;梯度画成预先分配好的缓冲)。权重、梯度和优化器状态是固定的底座;激活值在前向时逐层压栈,反向时按相反顺序弹出,曲线是一个锯齿。完全重算只存每层的输入,反向到某层时先把这一层的激活重算出来、用完即弃,峰值低得多,代价是每层多一次前向,整步更长。

选择性重算(selective)

完全重算有点浪费:并不是所有激活都同样「贵」。关键的观察是:注意力里 \(QK^\top\)、softmax、乘 V 这几步,激活值很大(带 \(as^2\) 项)但计算量相对小(每个元素只做很少的运算);而线性层的激活值小、重算却贵。所以只丢掉注意力核心部分,保留其余。公式里的 \(5as/h\) 项正是来自这部分:对 GPT-3(\(a=96, s=2048, h=12288\))\(5as/h = 80\),比 34 还大。Korthikanti 等人在 GPT-3 上测得,这样能省 70% 的激活显存,只多 2.7% 的 FLOPs。选择性重算后每层的激活值变成 \(34\,sbh\),和序列长度呈线性。

如果把一次迭代的 FLOPs 精确写出来(包括注意力和输出层),模型本身的 FLOPs 和选择性重算后硬件实际做的 FLOPs 分别是:

$$\text{model FLOPs} = 72BLsh^2\left(1 + \frac{s}{6h} + \frac{V}{12hL}\right),\qquad \text{hardware FLOPs} = 72BLsh^2\left(1 + \frac{s}{3h} + \frac{V}{12hL}\right)$$

(这里 \(B\) 是 batch 大小。)多出来的就是注意力的两个 \(s^2\) 矩阵乘法在前向里再做一遍,二者之比约为 \(1 + s/(6h)\)。

FlashAttention 已经是选择性重算

FlashAttention 在反向里本来就重算注意力分数和概率矩阵,而不是存下来。所以用 FlashAttention 的人,其实已经在做选择性重算了。另外,重算虽然增加了 FLOPs,却减少了对显存的读写;在显存带宽紧张的 GPU 上,总体常常反而更快。DeepSeek-V3 走得更远:反向时重算所有的 RMSNorm 和 MLA(多头潜在注意力)的上投影,这些输出一律不存。

其他策略

也可以从计算图的角度看这件事。如果什么都不重算,一层要存的中间节点大约有 20 个 \([B,T,D]\) 大小的张量(例如 \(f(x) = \exp(g(x))\) 的反向既要 \(g(x)\) 又要 \(\exp(g(x))\))。举个例子:\(B\cdot T = 4\text{M}\) token、\(L=64\)、\(D=8192\),BF16 下要存约 \(2\times 20\times BTDL\) ≈ 84 TB 的激活值。两种常用策略:

在 JAX 里这些策略由 jax.checkpoint(jax.remat)的 policy 控制;PyTorch 里对应 torch.utils.checkpoint,Megatron 有 --recompute-granularity full/selective。

补充:为什么是 \(\sqrt{L}\) 个检查点

这是 Chen 等人 2016 年的经典分析(《Training Deep Nets with Sublinear Memory Cost》)。假设 \(L\) 层,每层激活大小相同记为 1。每隔 \(k\) 层存一个检查点,就要存 \(L/k\) 个检查点;反向重算一段时,这一段的 \(k\) 层激活同时在显存里。峰值约为 \(L/k + k\),对 \(k\) 求最小得 \(k = \sqrt{L}\),峰值 \(2\sqrt{L}\),代价同样是多一次前向。Transformer 训练里更常见的是「每层一个检查点」,因为一层内部的激活远大于层输入,按层切天然合适。

重算还让硬件实际做的 FLOPs 变多了,所以衡量效率时要区分 HFU 和 MFU(第 13 节)。

9. 梯度累积

重计算压住了「每个样本」的激活值,但激活值仍和 batch size 成正比。梯度累积(gradient accumulation)解决这个问题:把一个大 batch 切成若干 micro-batch,依次对每个 micro-batch 做前向和反向,把梯度累加起来,最后才做一次优化器更新。

记每次前向的 batch 为 micro-batch size \(mbs\),两次优化器更新之间处理的总 batch 为 global batch size \(gbs\):

$$gbs = mbs \times grad\_acc$$

(有数据并行时,还要再乘上数据并行的副本数。)

optimizer.zero_grad()
for i, micro in enumerate(split(batch, grad_acc)):
    loss = model(micro).loss / grad_acc   # 取平均,结果与 grad_acc 无关
    loss.backward()                       # 梯度在 .grad 里累加
optimizer.step()

要点:

最后还有一个关键观察:各个 micro-batch 的前向和反向是彼此独立的,只差输入不同——那为什么不放到多张卡上并行做?这就是数据并行:它就是并行版本的梯度累积。

10. 数 FLOPs:从 \(2mnk\) 开始

FLOP 指一次浮点运算(一次乘或一次加),FLOPs 是运算次数,FLOP/s 是每秒运算次数(硬件峰值用它衡量)。

点积、矩阵向量积、矩阵乘法

推广到一般情形:两个高维数组做缩并(einsum)时,FLOPs = 2 × 所有出现维度的乘积,其中缩并维(两边都有、输出里没有,被求和掉的)和批维(两边都有、输出里也有)只算一次。例如 \(C[G,H,I,J,K,L]\cdot D[G,H,M,N,K,L]\to[G,H,I,J,M,N]\),\(G,H\) 是批维,\(K,L\) 是缩并维,FLOPs 为 \(2GHIJMNKL\)。没有缩并维时(纯逐元素乘)就没有那个 2。

值得记住的一点:矩阵乘法的计算量是 \(O(N^3)\),而数据量只有 \(O(N^2)\)。矩阵越大,越容易让计算单元「吃饱」。这正是深度学习架构由矩阵乘法主导的原因。

反向是前向的两倍

设 \(C = AB\),\(A\) 是 \([N,P]\) 的输入激活,\(B\) 是 \([P,M]\) 的权重。反向需要两个梯度:

$$\frac{\partial \mathcal{L}}{\partial B} = A^\top \frac{\partial \mathcal{L}}{\partial C}\quad(\text{缩并 } N,\ 2NPM),\qquad \frac{\partial \mathcal{L}}{\partial A} = \frac{\partial \mathcal{L}}{\partial C} B^\top\quad(\text{缩并 } M,\ 2NPM)$$
前向 反向:权重梯度 反向:输入梯度 A × B = C [N,P]·[P,M] → 2NPM Aᵀ × dC [P,N]·[N,M] → 2NPM dC × Bᵀ [N,M]·[M,P] → 2NPM 权重梯度要用前向的输入 A(所以要存激活);输入梯度传给前一层继续反向。
一个矩阵乘法在训练中变成三个同样大小的矩阵乘法:前向 2NPM,反向 4NPM。

所以训练时一个矩阵乘法总共 \(6NPM\) FLOPs:前向 \(2NPM\),反向 \(4NPM\),前向 : 反向 = 1 : 2。\(PM\) 正好是这个权重矩阵的参数量,\(N\) 是 token 数,于是每个 token 对每个参数花 6 FLOPs。这就是著名的 \(6 \times \text{参数量} \times \text{token 数}\) 的最简形式。

注意第一层(嵌入层后的第一个线性层)严格说不需要算输入梯度 \(\partial\mathcal{L}/\partial A\),因为没有更前面的可学习层需要它;但这只占一层的一部分,可以忽略。

11. Transformer 的总账:\(6ND\) 与注意力项

现在把整个 Transformer 的 FLOPs 加起来。为了让 einsum 的维度一目了然,换一套更紧凑的记号:batch \(B\)、序列长 \(T\)、模型维度 \(D\)(即前面的 \(h\))、MLP 维度 \(F\)、Q 头数 \(N\)(即 \(a\))、KV 头数 \(K\)、头维 \(H\)(即 \(d_h\))、词表 \(V\)。下表都是训练 FLOPs(前向 + 反向,已乘 3),除以 3 就是只做前向的量。

MLP(门控)

运算训练 FLOPs参数
\(A[B,T,D]\cdot W_{in1}[D,F]\)\(6BTDF\)\(DF\)
\(A[B,T,D]\cdot W_{in2}[D,F]\)\(6BTDF\)\(DF\)
\(\sigma(A_{in1}) \odot A_{in2}\)\(O(BTF)\)
\(A[B,T,F]\cdot W_{out}[F,D]\)\(6BTDF\)\(DF\)
合计\(\approx 18BTDF\)\(3DF\)

注意力投影(GQA)

运算训练 FLOPs参数
\(W_Q[D,N,H]\)\(6BTDNH\)\(DNH\)
\(W_K[D,K,H]\)、\(W_V[D,K,H]\)\(2\times 6BTDKH\)\(2DKH\)
\(W_O[N,H,D]\)\(6BTDNH\)\(DNH\)
合计\(12BTD(N+K)H\)\(2D(N+K)H\)

注意力核心(点积注意力本身)

把 Q 重排成 \([B,T,K,G,H]\)(\(G = N/K\) 是每组的 Q 头数)。\(Q\cdot K^\top\) 在 \(B, K\) 上是批维、在 \(H\) 上缩并,得到 \([B,T,S,K,G]\);softmax 是逐元素级的 \(O(BTSN)\);再乘 V 在 \(S\) 上缩并。两次矩阵乘法各 \(6BTSKGH = 6BTSNH\):

$$\text{attention FLOPs}_\text{train} \approx 12\,BTSNH = 12\,BT^2NH\quad(\text{自注意力 } S=T)$$

它不含任何参数,所以不在「6 × 参数量」里。对因果掩码,有用的 FLOPs 只有一半(需要注意力内核真的跳过被掩掉的块才能拿到这一半)。

其他

每层两个 LayerNorm/RMSNorm 只有 \(O(BTD)\),可以忽略。最后的输出层(unembedding)是一个 \(6BTDV\) 的矩阵乘法,参数 \(DV\),同样遵守 6 倍规则。输入嵌入是查表,不是矩阵乘法,严格说不花 FLOPs。

汇总成 6ND

忽略注意力核心,全部层加起来:

$$\big(18BTDF + 12BTD(N+K)H\big)L = 6\cdot BT\cdot\big(3DF + 2D(N+K)H\big)L = 6 \times \text{token 数} \times \text{参数量}$$

记 \(N_\text{params}\) 为参数量,\(D_\text{tokens}\) 为训练 token 数(这里的 \(D\) 和上面的模型维度不是一回事,这是领域里的历史记号),得到稠密 Transformer 训练算力的经验公式:

$$C_\text{train} \approx 6\,N_\text{params}\,D_\text{tokens}$$

验证一下:Llama 3 405B 训练了 15.6T token,\(6 \times 405\text{B} \times 15.6\text{T} \approx 3.79\times 10^{25}\),Llama 3 论文给的预训练算力正是 \(3.8\times 10^{25}\) FLOPs。

注意力项什么时候重要

假设典型配置 \(F = 4D\)、\(D = NH\)、\(N=K\)(MHA),注意力核心与所有矩阵乘法(含投影)之比是:

$$\frac{12BT^2NH}{18BTDF + 24BTDNH} = \frac{12BT^2D}{72BTD^2 + 24BTD^2} = \frac{T}{8D}$$

所以只有 \(T > 8D\) 时注意力 FLOPs 才占主导。对 \(D \approx 8\text{k}\) 的模型,要到约 64k token;Gemma 2 27B(\(D = 4608\))按这个估算大约在 37k token 时注意力开始占主导。这说明对大模型来说,注意力的二次方代价并不像直觉中那么可怕:模型越宽,MLP 越贵,注意力越不显眼。(另一个相关的结论:注意力核心 FLOPs 等于 QKVO 投影 FLOPs 的条件是 \(24BTDNH = 12BT^2NH\),即 \(T = 2D\)。)

拿 Llama 3 8B 在 8k 序列上具体算:\(6N\) ≈ 48.2 GFLOPs/token;注意力核心按因果减半是 \(6\cdot T\cdot NH\cdot L = 6 \times 8192 \times 4096 \times 32\) ≈ 6.4 GFLOPs/token,大约是 \(6N\) 的 13%。405B 在同样长度下只有约 4%。

也有人把每 token 的训练 FLOPs 写成 \(6N + 12\,L\,T\,d_\text{attn}\)(\(d_\text{attn} = NH\))的形式(不考虑因果减半),和上面是同一件事。

12. MoE 与 KV cache 的账

MoE

混合专家(Mixture of Experts)把每层的一个稠密 MLP 换成 \(E\) 个独立的 MLP(专家),每个 token 只路由到其中 \(k\) 个,通常 \(k \ll E\);\(E/k\) 叫稀疏度,常见在 8 到 64 之间(例如 DeepSeek-V3 每层有 256 个路由专家,每个 token 选 8 个,另有 1 个所有 token 都经过的共享专家)。参数量按 \(E\) 增长,每个 token 实际经过的参数(激活参数)只按 \(k\) 增长。所以算 FLOPs 时要用激活参数:

$$C_\text{train}^\text{MoE} \approx 6\,N_\text{active}\,D_\text{tokens}$$

而显存账要按总参数算——所有专家的权重、梯度和优化器状态都得存着。这就是 MoE「算得少、存得多」的特点,也是后面专家并行要解决的问题。MoE 还引入新的通信:每个 MoE 层前后各一次 AllToAll,把 token 发到专家所在的卡上再收回来。

KV cache(推理时的显存账)

推理分为 prefill(处理整个提示)和逐 token 的生成。prefill 时把每层的 K、V 投影存下来,生成时复用,这就是 KV cache。每条序列的 KV cache 是一个 \([2, S, L, K, H]\) 的数组(2 代表 K 和 V),字节数为:

$$m_\text{KV} = 2\cdot S\cdot L\cdot K\cdot H \cdot \text{bytes}$$

Llama 3 8B(\(L=32, K=8, H=128\))用 BF16 存:每 token \(2\times 32\times 8\times 128\times 2\) 字节 = 128 KiB,一条 8k 序列约 1.07 GB。如果是 MHA(\(K = N = 32\)),会是 4 倍。这就是 GQA/MQA 的动机。推理的完整账见Transformer 推理的数学。

13. MFU 与 HFU

知道了一步需要多少 FLOPs,就能衡量训练「用满」了多少硬件。设一步的时长为 \(t_\text{step}\),单卡峰值算力为 \(P_\text{peak}\)(FLOP/s),卡数为 \(n\):

$$\text{MFU} = \frac{\text{模型 FLOPs}/t_\text{step}}{n\cdot P_\text{peak}},\qquad \text{HFU} = \frac{\text{硬件 FLOPs}/t_\text{step}}{n\cdot P_\text{peak}}$$

为什么更看重 MFU?因为最终关心的是在给定数据上训完要多久。如果一种硬件显存足够大、不需要重算,总 FLOPs 更少却训得更快,它应该被奖励而不是被惩罚。HFU 会奖励「多做无用功」,MFU 不会,所以 MFU 更多反映模型和训练方案本身,便于跨实现比较。

两者的关系:完全重算下,硬件 FLOPs ≈ \(8ND\),模型 FLOPs = \(6ND\),于是 \(\text{HFU} \approx \tfrac{4}{3}\,\text{MFU}\);选择性重算下,比值约为 \(1 + s/(6h)\)。

几个真实数字

小心规格表上的数字。NVIDIA 规格表里 Tensor Core 的数字通常是「带结构化稀疏」的,是稠密值的 2 倍。H100 SXM 规格表写 BF16 1,979 TFLOPS 并标了星号,稠密值是 989。算 MFU 时要用稠密峰值,否则 MFU 会被低估一半。

一个好用的换算

把 MFU 换算成每卡吞吐:

$$\text{tokens/s/GPU} = \frac{\text{MFU}\cdot P_\text{peak}}{6N}$$

Llama 3 8B 在 H100 上、MFU 40% 时:\(0.4 \times 989\text{e}12 / (6 \times 8.03\text{e}9) \approx 8{,}200\) token/s/GPU。这个数在看训练日志时非常有用:日志报的吞吐乘以 \(6N\) 再除以峰值,就是你的 MFU。(长序列时别忘了加上注意力项,否则 MFU 会被低估。)

14. 交互:训练要多少天

把上面的东西串起来:

$$t_\text{train} = \frac{C_\text{train}}{n \cdot P_\text{peak} \cdot \text{MFU}}$$

下面的估算器用 \(6ND\) 加上注意力项(按因果减半),也给出完全重算时的 HFU。峰值都取 BF16 稠密值:H100/H200 989、B200 2,250(HGX B200 的规格写的是 8 卡 36 PFLOPS,这是稀疏值,稠密减半后每卡 2.25 PFLOPS)、A100 312、TPU v5p 459、TPU v5e 197 TFLOP/s。

交互演示 · 训练时长估算器
预设
参数量 N
token 数 D
序列长度
GPU 数
MFU
芯片
FLOPs 构成:

注意力项按 \(6\cdot T\cdot (a\,d_h)\cdot L\) FLOPs/token 估算(训练、因果减半),其中层数和注意力宽度由参数量按 Llama 3 的形状插值得到。HFU 一栏假设完全重算(多一遍前向)。

默认是 Llama 3 405B 的配置:15.6T token、16,384 张 H100、MFU 41%,只算 \(6ND\) 是约 66 天,加上 8k 序列的注意力项约 69 天。作为对照,Llama 3.1 的模型卡记录 405B 一共用了 30.84M H100 GPU 小时,按 16,384 张卡均摊是约 1,880 小时、78 天。反过来算平均利用率:\(3.8\text{e}25 / (30.84\text{e}6 \times 3600 \times 989\text{e}12) \approx 35\%\),比上一节稳态运行时的 38–43% 低。差距来自稳态之外的时间:GPU 小时是整个训练的总账,包括 MFU 本来就更低的长上下文阶段,也包括故障带来的中断和重启。Llama 3 预训练期间,54 天里发生了 466 次作业中断,有效训练时间约占 90%。

15. 小结:一张速查表

量公式备注
参数量(Llama 式)\(L(2h\,ad_h + 2h\,h_{kv} + 3hF + 2h) + 2Vh\)粗略 \(\approx 12Lh^2\)(GPT 式)
静态显存\(16N\)(或 \(20N\))字节BF16 混合 + FP32 主权重 + Adam
激活(GPT 结构)\(L\,sbh(34 + 5as/h)\) 字节含 dropout 掩码、朴素注意力
激活(Llama 式)\(L\cdot 2sb(6h + 2h_{kv} + 4F)\) 字节FlashAttention、无 dropout
完全重算后\(L\cdot 2sbh\) + 一层工作集FLOPs \(6ND\to 8ND\)
全局 batch\(gbs = mbs \times grad\_acc\)数据并行后再乘 \(dp\)
矩阵乘法\(2mnk\);训练 \(6mnk\)前向 : 反向 = 1 : 2
训练算力\(6ND\) + \(12BT^2NH\cdot L\)注意力在 \(T > 8D\) 后占主导
MoE\(6N_\text{active}D\)显存按总参数
KV cache\(2SLKH\) × 字节每条序列
MFU\(\frac{6N\cdot \text{tokens/s}}{n\,P_\text{peak}}\)用稠密峰值

回到开头的问题:Llama 3 8B 的 16 字节/参数就已经是 128 GB,激活值还会再加几十 GB——单卡装不下,这是必须多卡训练的直接原因。但切模型之前还有一个问题:给定的一次运算,瓶颈到底在算力、显存带宽还是网络上?Roofline 回答的就是这个。

参考与延伸阅读

这一篇的显存部分主要整理自 Ultra-Scale Playbook 的前两章,FLOPs 部分主要整理自 How to Scale Your Model 的第 4 章。想看真实的显存 profiling 曲线和更多实验,推荐直接读 Playbook 的「First Steps: Training on One GPU」;第 4 章末尾的习题也很值得做一遍。