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) Falsex 的 stride 是 (4, 1):下一行跳 4 个元素,下一列跳 1 个。取一行、取一列、转置,都只是换一组 stride,和原张量共享同一块内存,所以改 y 会改到 x。这类操作是免费的。但转置后的张量不再”连续”(按行读时内存地址不是递增的),view 要求新形状能用一组 stride 表达在原内存上,做不到就报错;reshape 在做不到时会悄悄复制一份;contiguous() 则显式复制成连续的布局。复制意味着额外的显存和内存带宽,在大张量上不是小事。
x 是形状 (3, 4) 的连续张量,t = x.T。下列哪个操作会分配新的内存?
t 不连续,展平成一维无法用一组 stride 描述原内存,reshape 只能复制一份;正文里它的 storage 地址和 x 不同。
索引取行、取列只改 stride 和偏移,共享内存。x 本身是连续的,view(4, 3) 只需新的 stride (3, 1),不复制;注意它和 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?
前向一个矩阵乘 2BP。反向需要对输入的梯度(继续往前一层传)和对权重的梯度(更新本层),各是一个同规模矩阵乘,共 4BP。
只算权重梯度的话前一层收不到梯度。优化器更新是逐元素操作,与 P 成正比但与 B 无关,相比 6BP 可以忽略。
算术强度: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 模型,下列说法哪些正确?
每参数 16 字节,13B 乘 16 是 208 GB,这部分只取决于参数量。激活与 token 数成正比,batch 翻倍它就翻倍。
混合精度里 Adam 的两个矩和主权重仍是 fp32,12 字节/参数不变,换 bf16 权重只省了一小部分。26 GB 只够放 bf16 权重做推理,训练还要放梯度、优化器状态和激活。
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.91220 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 倍,最优的模型大小和数据量应该怎么变?
C = 6ND 且 D = 20N,所以 C = 120N²,N 与 C 的平方根成正比;算力 4 倍,N 变 2 倍,D 也变 2 倍。
只加模型是 Kaplan 时代的倾向,导致了 Gopher 这样数据不足的模型。只加数据在推理受限时可能是合理选择,但不是训练算力最优。最后一项乘起来算力不变,不符合题意。
μ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。一步更新对该层输出的改变量大约怎么变?
初始化缩放只保证了 xW 的尺度与宽度无关。更新 ΔW 的每个元素约为 lr(Adam 对梯度做了归一化),且梯度是外积、与 x 对齐,所以 x·ΔW 是 n 个同号项之和,正比于 n,正文实测 0.81 到 3.26。
Adam 的更新大小与梯度绝对值无关,梯度变小不会让更新变小。参数量增长 16 倍,但每个输出只与一行 n 个权重相关,所以是 n 倍。
记住这三件事
- 训练算力
C ≈ 6ND:每个参数每个 token 前向 2、反向 4 FLOPs;长上下文时注意力分数的T²项会超过它。用 MFU 把 FLOPs 换算成 GPU 小时。 - 显存是每参数 16 字节(混合精度 AdamW)加激活;FLOPs 只决定大矩阵乘的时间,其余操作受内存带宽限制,看算术强度。
- loss 是规模的幂律,可以从小模型外推;Chinchilla 说训练算力最优是约 20 tokens/param、N 和 D 同步增长,但考虑推理成本时会刻意过训练;μP 让超参数在宽度之间可迁移。
延伸阅读
- Stanford CS336 Lecture 2(PyTorch 与资源核算)、Lecture 9 与 11(Scaling Laws)
- Kaplan et al. 2020: Scaling Laws for Neural Language Models
- Hoffmann et al. 2022: Training Compute-Optimal Large Language Models 与 Besiroglu et al. 2024: Chinchilla Scaling: A replication attempt
- Korthikanti et al. 2022: Reducing Activation Recomputation in Large Transformer Models
- Yang et al. 2022: Tensor Programs V (μP)
- Transformer Math 101 (EleutherAI)