LLaMA 2 7B 用了 2T token 训练。如果你有 1024 张 H100,大概要训多久?要回答这个问题,不需要跑任何代码,只需要两个数:模型参数量和 token 数,加上一条 6ND 的公式。这一篇先学会给训练”记账”:每个数占几个字节、每次矩阵乘多少 FLOPs、显存都花在哪;然后回答一个更难的问题:给定一笔算力预算,模型该多大、数据该多少。

前置:Transformer 架构、信息的表示(浮点数)

一切从张量开始:字节、dtype 与内存布局

账本的最小单位是张量里的一个元素。它占几个字节由 dtype 决定:

import torch
for dt in (torch.float32, torch.float16, torch.bfloat16):
    fi = torch.finfo(dt)
    print(f"{str(dt):15s} bytes={fi.bits//8}  max={fi.max:.2e}  eps={fi.eps:.1e}")
x = torch.tensor(70000.0)
print(x.to(torch.float16), x.to(torch.bfloat16))
print(torch.tensor(1.0, dtype=torch.bfloat16) + 0.001)
torch.float32   bytes=4  max=3.40e+38  eps=1.2e-07
torch.float16   bytes=2  max=6.55e+04  eps=9.8e-04
torch.bfloat16  bytes=2  max=3.39e+38  eps=7.8e-03
tensor(inf, dtype=torch.float16) tensor(70144., dtype=torch.bfloat16)
tensor(1., dtype=torch.bfloat16)

fp16 和 bf16 都是 2 字节,分法不同。fp16 给尾数更多位,精度高但最大只能表示 65504,70000 直接变成 inf,训练时梯度和激活很容易溢出。bf16 的指数位和 fp32 一样多(8 位),范围几乎相同,代价是精度很低:1 + 0.001 在 bf16 里还是 1。深度学习对范围比对精度敏感,所以 bf16 成了训练的默认格式;但累加(比如优化器更新、loss 规约)仍然要在 fp32 里做,否则小的更新会被吞掉。这就是混合精度训练(细节见 并行训练)。

元素数乘字节数是总大小,但张量在内存里的排列方式同样会影响开销。一个 PyTorch 张量由一块一维的 storage 加上一组 stride 组成:stride 告诉你沿每一维走一步要在 storage 里跳几个元素。

import torch
x = torch.arange(12, dtype=torch.float32).reshape(3, 4)
print(x.stride(), x.element_size(), x.untyped_storage().nbytes())
y = x[1]            # 第 2 行
z = x[:, 1]         # 第 2 列
print(y.stride(), z.stride(), z.is_contiguous())
print(y.untyped_storage().data_ptr() == x.untyped_storage().data_ptr())
y[0] = 100.0
print(x[1, 0])
t = x.T
print(t.stride(), t.is_contiguous())
try:
    t.view(-1)
except RuntimeError as e:
    print("view failed:", str(e)[:60])
r = t.reshape(-1)
print(r.untyped_storage().data_ptr() == x.untyped_storage().data_ptr())
c = t.contiguous()
print(c.stride(), c.untyped_storage().data_ptr() == x.untyped_storage().data_ptr())
(4, 1) 4 48
(1,) (4,) False
True
tensor(100.)
(1, 4) False
view failed: view size is not compatible with input tensor's size and str
False
(3, 1) False

x 的 stride 是 (4, 1):下一行跳 4 个元素,下一列跳 1 个。取一行、取一列、转置,都只是换一组 stride,和原张量共享同一块内存,所以改 y 会改到 x。这类操作是免费的。但转置后的张量不再”连续”(按行读时内存地址不是递增的),view 要求新形状能用一组 stride 表达在原内存上,做不到就报错;reshape 在做不到时会悄悄复制一份;contiguous() 则显式复制成连续的布局。复制意味着额外的显存和内存带宽,在大张量上不是小事。

停一下,检查理解单选

x 是形状 (3, 4) 的连续张量,t = x.T。下列哪个操作会分配新的内存?

数 FLOPs:为什么是 6ND

模型的算力几乎全部花在矩阵乘上。(m×k) 乘 (k×n) 的矩阵乘,输出有 m·n 个元素,每个是 k 次乘加,共 2mnk 次浮点运算(乘和加各算一次)。

把它用到一个 Linear 层:输入 X 是 (B×d_in)(B 是 token 数),权重 W 是 (d_in×d_out)。前向 Y = XW 是 2·B·d_in·d_out,正好是 2 × token 数 × 参数量。反向要算两个梯度:∂L/∂X = (∂L/∂Y) Wᵀ,用来继续往前传;∂L/∂W = Xᵀ (∂L/∂Y),用来更新参数。每个都是同样规模的矩阵乘,反向合计 4·B·参数量。前向加反向,每个参数每个 token 6 FLOPs。整个模型的参数几乎都在这些矩阵乘里,所以训练总计算量:

N 是参数量,D 是训练 token 数。这个公式漏掉了注意力里 QKᵀ 和 AV 这两个不含参数的矩阵乘。它们有多大?以 GPT-2 XL 的形状(48 层、d_model = 1600、词表 50257,FFN 换成 SwiGLU、d_ff = 6400)逐项数一遍:

V, L, d, H, dff = 50257, 48, 1600, 25, 6400        # GPT-2 XL 形状,FFN 换成 SwiGLU
attn = 4 * d * d                  # Wq Wk Wv Wo
ffn = 3 * d * dff                 # SwiGLU 三个矩阵
per_layer = attn + ffn + 2 * d    # 加两个 RMSNorm
N = L * per_layer + 2 * V * d + d # 输入 embedding 与 lm_head 不共享,加 final norm
print(f"per layer {per_layer/1e6:.2f}M, total {N/1e9:.3f}B, fp32 weights {N*4/2**30:.2f} GiB")
for T in (1024, 16384):
    # 一条长度 T 的序列的前向 FLOPs,只数矩阵乘(一次乘加 = 2 FLOPs)
    parts = {"attn proj": L * 2 * T * attn,
             "QK^T+AV":   L * 2 * 2 * T * T * d,    # 两个 (T×d)(d×T) 规模的矩阵乘
             "FFN":       L * 2 * T * ffn,
             "lm_head":   2 * T * V * d}
    tot = sum(parts.values())
    print(f"T={T}: total {tot/1e12:.2f} TFLOPs, 2NT = {2*N*T/1e12:.2f}  |  " +
          "  ".join(f"{k} {v/tot:.0%}" for k, v in parts.items()))
per layer 40.96M, total 2.127B, fp32 weights 7.92 GiB
T=1024: total 4.51 TFLOPs, 2NT = 4.36  |  attn proj 22%  QK^T+AV 7%  FFN 67%  lm_head 4%
T=16384: total 149.52 TFLOPs, 2NT = 69.70  |  attn proj 11%  QK^T+AV 55%  FFN 32%  lm_head 2%

上下文 1024 时,2NT 和逐项求和只差 3%(2NT 多算了 embedding 查表,少算了注意力分数),FFN 占了三分之二。上下文拉到 16384,QKᵀ 和 AV 随 T² 增长,变成最大的一项,2NT 低估了一半多。所以 6ND 是”上下文长度远小于 d_model 的若干倍”时的近似;一个常用的修正是每个 token 再加 6·n_layer·T·d_model(按完整注意力矩阵计)。

现在回答开头的问题。LLaMA 2 7B: FLOPs。H100 的 bf16 稠密峰值约 989 TFLOP/s,但实际训练只能用到一部分,实际吞吐除以峰值叫 MFU(Model FLOPs Utilization),大规模训练做到 40% 到 50% 就算不错。按 40% 算,需要 GPU 秒,约 5.6 万 H100 小时,1024 张卡约 2.3 天。反过来验证:Meta 报告 LLaMA 2 7B 用了 184320 个 A100 小时,A100 bf16 峰值 312 TFLOP/s,倒推 MFU 约 39%,和这个假设一致。

停一下,检查理解单选

一个 Linear 层有 P 个参数,处理 B 个 token。一次训练步(前向加反向)大约多少 FLOPs?

算术强度:FLOPs 不是全部

如果只数 FLOPs,会以为 RMSNorm、激活函数、残差相加这些操作几乎不花时间。在同一台机器上比较一下矩阵乘和逐元素乘法的实际吞吐(这是一台 Apple Silicon 笔记本,矩阵乘走专门的加速单元):

import torch, time
torch.set_num_threads(1)
def bench(f, flops, nbytes, reps=20):
    f(); t = time.perf_counter()
    for _ in range(reps): f()
    dt = (time.perf_counter() - t) / reps
    return flops / dt / 1e9, flops / nbytes
for n in (64, 256, 1024, 2048):
    a, b = torch.randn(n, n), torch.randn(n, n)
    g, ai = bench(lambda: a @ b, 2 * n**3, 3 * n * n * 4)   # 读 a、b,写结果
    print(f"matmul n={n:4d}  intensity {ai:6.1f} FLOP/B  {g:7.1f} GFLOP/s")
x = torch.randn(2048 * 2048)
g, ai = bench(lambda: x * 2.0, x.numel(), 2 * x.numel() * 4)   # 读一遍、写一遍
print(f"x*2   n=2048^2 intensity {ai:6.3f} FLOP/B  {g:7.1f} GFLOP/s")
matmul n=  64  intensity   10.7 FLOP/B    214.5 GFLOP/s
matmul n= 256  intensity   42.7 FLOP/B    808.5 GFLOP/s
matmul n=1024  intensity  170.7 FLOP/B    927.5 GFLOP/s
matmul n=2048  intensity  341.3 FLOP/B    908.4 GFLOP/s
x*2   n=2048^2 intensity  0.125 FLOP/B      6.7 GFLOP/s

同一台机器,有效算力差了 135 倍。区别在于算术强度:每从内存搬一个字节做多少次运算。n×n 方阵乘法搬 3n² 个元素、做 2n³ 次运算,强度随 n 线性增长;逐元素乘每个元素读一次写一次,只做 1 次运算,fp32 下强度 0.125。硬件每秒能算的 FLOPs 和每秒能搬的字节数之比是一个固定值(H100 约 989 TFLOP/s ÷ 3.35 TB/s ≈ 295 FLOP/B)。强度低于这个值的操作,时间由内存带宽决定,算力闲着;高于它的才受算力限制。小矩阵(n=64)也一样吃亏。这个分析框架叫 roofline,它和 kernel fusion(把多个逐元素操作合成一次读写)见 GPU 与 Kernel。

对资源核算来说,结论是:FLOPs 决定了大矩阵乘的时间,带宽决定了其余一切的时间。 这也是为什么 MFU 到不了 100%,以及为什么 decode 阶段(每步只处理一个 token,矩阵乘退化成矩阵乘向量)的瓶颈是带宽(见 推理系统)。

显存核算:参数之外的大头

训练时显存里装着四样东西:参数、梯度、优化器状态、激活。前三样只和参数量有关。以 bf16 混合精度加 AdamW 为例,LLaMA 2 7B:

N = 6.7e9
state = {"bf16 weights": 2, "bf16 grads": 2, "fp32 master weights": 4, "Adam m (fp32)": 4, "Adam v (fp32)": 4}
for k, v in state.items():
    print(f"{k:20s} {v} B/param  {N*v/2**30:6.1f} GiB")
print(f"{'total':20s} {sum(state.values())} B/param  {N*sum(state.values())/2**30:6.1f} GiB")
# 激活(Korthikanti et al. 2022 的估计,16-bit、无重计算、无张量并行):每层 s*b*h*(34 + 5*a*s/h) 字节
s, b, h, a, L = 4096, 1, 4096, 32, 32
full = L * s * b * h * (34 + 5 * a * s / h)
no_scores = L * s * b * h * 34          # 不存 s×s 的注意力矩阵(FlashAttention 的做法)
print(f"activations per seq: {full/2**30:.1f} GiB, without attention matrix {no_scores/2**30:.1f} GiB")
bf16 weights         2 B/param    12.5 GiB
bf16 grads           2 B/param    12.5 GiB
fp32 master weights  4 B/param    25.0 GiB
Adam m (fp32)        4 B/param    25.0 GiB
Adam v (fp32)        4 B/param    25.0 GiB
total                16 B/param    99.8 GiB
activations per seq: 97.0 GiB, without attention matrix 17.0 GiB

每个参数 16 字节:权重本身只占 2 字节,Adam 的一阶矩、二阶矩加上 fp32 的主权重占了 12 字节。一个 7B 模型还没开始算,显存就需要 100 GiB,单张 80GB 的卡放不下。这就是 ZeRO/FSDP 要把优化器状态切到多张卡上的原因(见 并行训练)。

激活是反向传播要用到的中间结果,和 batch、序列长度成正比。上面用的是 Megatron 论文对 GPT 结构的估计:每层每个 token 约 34·d_model 字节,外加注意力矩阵 5·n_head·s 字节。序列 4096 时,一条序列的激活就有 97 GiB,其中 80 GiB 是 s×s 的注意力分数和 softmax 结果。FlashAttention 不把这个矩阵存下来,激活降到 17 GiB;再进一步,可以只存每层的输入、反向时重算中间结果(activation checkpointing),用约 33% 的额外计算换显存。

停一下,检查理解多选

用 bf16 混合精度 + AdamW 训练一个 13B 模型,下列说法哪些正确?

Scaling law:loss 是算力的幂函数

账算清楚之后,下一个问题是:这些算力花出去能换来多少 loss 的下降?

Kaplan et al. 2020 在 OpenAI 训练了大量不同大小的模型,发现一个惊人地干净的规律:在不被其他因素卡住时,测试 loss 与参数量 N、数据量 D、算力 C 各自都呈幂律关系,在 log-log 图上是直线,跨越七八个数量级。幂律意味着两件事:一是每把规模扩大 10 倍,loss 按一个固定的比例下降,收益递减但永不饱和;二是可以用一系列小模型拟合出直线,外推到大模型,不必在大模型上做超参数搜索。

为什么是幂律而不是指数?一个直觉来自统计:估计一个均值,误差按 下降,log-log 上斜率是 −1;做 d 维非参数回归,误差按 下降,斜率是 。语言模型的数据 scaling 指数大约是 −0.095,像是在一个”有效维度”很高的空间里做回归。这只是直觉,不是证明。

Chinchilla:给定算力,模型该多大

固定算力 C,因为 ,加大模型就意味着少看数据。哪个分配最好?Kaplan 的结论是应该优先加参数(N 随 C 的 0.73 次方增长),于是 GPT-3(175B 参数,300B token)和 Gopher(280B,300B token)都是”大模型、少数据”。

Hoffmann et al. 2022(DeepMind 的 Chinchilla 论文)用三种方法重新做了这个实验,结论是 N 和 D 应该同比例增长,最优比例约为每个参数 20 个 token。最直观的是 IsoFLOP 方法:固定几档算力,每档训一组不同大小的模型(数据量随之调整),loss 对模型大小画出来是一个 U 形,取谷底就是这档算力的最优模型;再把各档的谷底连起来拟合幂律。用 20 tokens/param 算一下 Gopher 的预算:

第三种方法是直接拟合一个参数化的 loss 曲面 :E 是数据本身的熵(无法消除的部分),后两项分别是模型太小和数据太少造成的损失。在 约束下可以解析地求最优分配。下面把论文公布的系数,和 Besiroglu et al. 2024 重新拟合的系数都代进去:

import math
C = 5.76e23
N = math.sqrt(C / (6 * 20)); D = 20 * N
print(f"20 tok/param: N = {N/1e9:.1f}B, D = {D/1e12:.2f}T")
def opt(E, A, B, a, b, C):
    # 在 C = 6ND 约束下,对 L = E + A/N^a + B/D^b 求最优:解析解
    G = (a * A / (b * B)) ** (1 / (a + b))
    N = G * (C / 6) ** (b / (a + b)); D = C / (6 * N)
    return N, D, E + A / N**a + B / D**b
for name, p in [("Hoffmann 2022", (1.69, 406.4, 410.7, 0.34, 0.28)),
                ("Besiroglu 2024", (1.8172, 482.01, 2085.43, 0.3478, 0.3658))]:
    for c in (1e21, 5.76e23, 1e25):
        N, D, L = opt(*p, c)
        print(f"{name:14s}  C={c:.2e}  N={N/1e9:7.1f}B  D={D/1e12:7.2f}T  D/N={D/N:5.1f}  L={L:.3f}")
20 tok/param: N = 69.3B, D = 1.39T
Hoffmann 2022   C=1.00e+21  N=    1.8B  D=   0.09T  D/N= 50.1  L=2.329
Hoffmann 2022   C=5.76e+23  N=   32.2B  D=   2.98T  D/N= 92.6  L=1.931
Hoffmann 2022   C=1.00e+25  N=  116.8B  D=  14.27T  D/N=122.1  L=1.845
Besiroglu 2024  C=1.00e+21  N=    2.8B  D=   0.06T  D/N= 21.6  L=2.306
Besiroglu 2024  C=5.76e+23  N=   72.2B  D=   1.33T  D/N= 18.4  L=1.974
Besiroglu 2024  C=1.00e+25  N=  312.1B  D=   5.34T  D/N= 17.1  L=1.912

20 tokens/param 给出 69B 参数、1.39T token,DeepMind 正是按这个配置训练了 Chinchilla(70B,1.4T token),用和 Gopher 相同的算力,效果全面超过了 4 倍大的 Gopher。但输出里有个矛盾:论文自己公布的曲面系数给出的比例是 50 到 120,并且随算力漂移,和前两种方法的 20 对不上。Epoch AI 的 Besiroglu 等人从论文图中重建数据重新拟合,发现原拟合有问题(优化过程提前停止、系数的报告精度不够),修正后的系数给出 17 到 22 的比例,与 IsoFLOP 方法一致。教训是:同样叫 “scaling law”,拟合方法和细节能让结论差好几倍,外推之前要用多种方法交叉验证。Kaplan 和 Chinchilla 本身的分歧,后来的工作也归因到一串细节上:Kaplan 数参数时不算 embedding、学习率调度没有随训练长度调整、所用的模型偏小。

还要注意 Chinchilla 回答的是”训练算力固定时 loss 最低”的分配。模型训完要被部署、被调用几十亿次,推理成本与 N 成正比。所以现在的模型普遍”过训练”:LLaMA 3 8B 用了 15T token,约 1900 tokens/param,远超 20。多花训练算力,换一个更小、推理更便宜的模型。

停一下,检查理解单选

按 Chinchilla 的 20 tokens/param,把训练算力增加到原来的 4 倍,最优的模型大小和数据量应该怎么变?

μP:让小模型上调好的超参数能搬到大模型

scaling law 依赖一个前提:每个规模的模型都用了各自最好的超参数。可是在大模型上调学习率太贵了。如果在宽度 256 的模型上找到的最优学习率,宽度 4096 时还能用,就省了大量算力。标准参数化下这不成立:模型越宽,最优学习率越小,而且没有简单的换算。

原因可以直接算出来。一个 Linear 层用 方差初始化,输出的尺度和宽度无关。但更新量不是这样:Adam 在训练早期近似于符号更新,每个权重挪动 lr 左右;而权重的梯度是输入与上游梯度的外积,和输入方向对齐,于是 n 个对齐的小改变在输出端加起来,变化量正比于 n。

import torch
torch.manual_seed(0)
lr = 1e-3
for n in (256, 1024, 4096):
    x = torch.randn(1, n)                           # 输入每个分量 O(1)
    W = torch.randn(n, n) / n**0.5                  # 1/fan_in 初始化:输出每个分量 O(1)
    delta = torch.randn(1, n)                       # 从上游传回的梯度
    grad = x.T @ delta                              # Linear 的权重梯度是外积,和 x 对齐
    for name, step in [("same lr", lr), ("lr*256/n", lr * 256 / n)]:
        dW = -step * grad.sign()                    # Adam 早期近似 sign 更新:每个元素挪 lr
        print(f"n={n:4d} {name:9s} |xW| {(x @ W).abs().mean():.2f}   |x dW| {(x @ dW).abs().mean():.3f}")
n= 256 same lr   |xW| 0.81   |x dW| 0.190
n= 256 lr*256/n  |xW| 0.81   |x dW| 0.190
n=1024 same lr   |xW| 0.80   |x dW| 0.813
n=1024 lr*256/n  |xW| 0.80   |x dW| 0.203
n=4096 same lr   |xW| 0.80   |x dW| 3.260
n=4096 lr*256/n  |xW| 0.80   |x dW| 0.204

同一个学习率,宽度每扩大 4 倍,一步更新带来的输出变化就扩大 4 倍,而输出本身的尺度不变;宽到一定程度,一步更新就把激活冲毁了。把隐藏层的学习率按 缩放,更新的相对大小就和宽度无关了。

μP(Maximal Update Parametrization,Yang et al. 2022)把这个推理系统化:要求在宽度趋于无穷时,每层的激活保持 O(1),且每一步更新对激活的改变也保持 O(1)。对 Adam 来说,结论大致是:隐藏层权重按 初始化,学习率按 缩放;输出层的 logits 额外乘一个 的系数。满足这两条后,最优学习率随宽度基本不动,可以在小模型上调好直接搬到大模型。Cerebras-GPT、MiniCPM 等都报告用 μP 做了超参数迁移。它的局限是只管宽度(深度需要额外的处理),而且某些改动(比如特定的 norm 或优化器)会破坏它的前提。

另一条不依赖 μP 的路线是直接拟合超参数自己的 scaling law:在多个规模上网格搜索最优学习率和 batch size,拟合它们随算力变化的幂律再外推,DeepSeek LLM 就是这样做的。它们还用 WSD(warmup-stable-decay)这类学习率调度替代 cosine:stable 阶段的 checkpoint 可以在任何时候接一段短的 decay 得到一个”训完”的模型,一次训练就能得到 Chinchilla 分析需要的多个数据量点。

停一下,检查理解单选

标准参数化下,固定 Adam 的学习率,把隐藏层宽度从 1024 加到 4096。一步更新对该层输出的改变量大约怎么变?

记住这三件事

  1. 训练算力 C ≈ 6ND:每个参数每个 token 前向 2、反向 4 FLOPs;长上下文时注意力分数的 T² 项会超过它。用 MFU 把 FLOPs 换算成 GPU 小时。
  2. 显存是每参数 16 字节(混合精度 AdamW)加激活;FLOPs 只决定大矩阵乘的时间,其余操作受内存带宽限制,看算术强度。
  3. loss 是规模的幂律,可以从小模型外推;Chinchilla 说训练算力最优是约 20 tokens/param、N 和 D 同步增长,但考虑推理成本时会刻意过训练;μP 让超参数在宽度之间可迁移。

延伸阅读