一个 7B 参数的模型,权重用 bf16 存只要 14 GB,一张 80 GB 的 H100 绰绰有余。可一旦开始用 AdamW 训练,每个参数要背上约 16 字节的状态,7B × 16 = 112 GB,激活还没算,一张卡已经装不下了。就算装得下,按 资源核算与 Scaling Laws 的 6ND 估算,用一张卡训完几万亿 token 要几十年。并行训练要回答的就是:模型、状态和计算怎么切到很多张卡上,切完以后卡和卡之间要传什么、传多少、能不能藏在计算后面。
前置:GPU 与 Kernel(显存层级与带宽)、Transformer 架构(一层由哪些矩阵组成)
一张卡上装了什么:混合精度与 AdamW
先把单卡的账算清楚,因为后面所有切法都是在切这本账。现代训练用混合精度:前向和反向的矩阵乘用 bf16(GPU 与 Kernel 里讲过,Tensor Core 的低精度路径更快、字节更少),但参数的”正本”用 fp32 保存。为什么不全用 bf16?看一个最小的例子:权重是 1.0,每步减去 1e-3,走 1000 步。
import torch
w = torch.tensor(1.0, dtype=torch.bfloat16)
for _ in range(1000): w = w + torch.tensor(-1e-3, dtype=torch.bfloat16)
master = torch.tensor(1.0, dtype=torch.float32)
for _ in range(1000): master -= 1e-3
print("bf16 weight after 1000 steps of -1e-3:", w.item())
print("fp32 master after 1000 steps: ", round(master.item(), 4))
print("bf16 spacing near 1.0:", torch.finfo(torch.bfloat16).eps)bf16 weight after 1000 steps of -1e-3: 1.0
fp32 master after 1000 steps: 0.0
bf16 spacing near 1.0: 0.0078125bf16 只有 7 位尾数,1.0 附近相邻两个数相差 0.0078,减 0.001 每次都被舍入回 1.0,一千步白走。学习率乘梯度通常就是这么小的数(位布局见 信息的表示)。所以要有一份 fp32 的 master weights 接收更新,每步再转一份 bf16 给前向用。同样的道理,归约(sum、softmax、LayerNorm)和 loss 在 fp32 里算;用 fp16 而非 bf16 时,因为指数范围小,还要做 loss scaling 防止梯度下溢,bf16 的指数位和 fp32 一样多,通常不需要。
再看优化器。AdamW 给每个参数维护两个状态:一阶矩 m(梯度的指数滑动平均)和二阶矩 v(梯度平方的滑动平均),更新时用 m / (sqrt(v) + eps) 当步长方向,相当于每个参数自己调学习率(Adam 的来历见 训练深度网络与 CNN)。AdamW 的 W 指解耦的权重衰减:不是把 λθ 加进梯度(那样它会被 sqrt(v) 除掉,梯度大的参数反而衰减少),而是直接把参数乘以 1 − lr·λ。CS336 作业里的实现核心只有几行:
m.mul_(beta1).add_(grad, alpha=1 - beta1)
v.mul_(beta2).addcmul_(grad, grad, value=1 - beta2)
# 两个偏差修正合并进步长:代数上等价,少一个临时张量
step_size = lr * math.sqrt(1 - beta2**t) / (1 - beta1**t)
p.addcdiv_(m, v.sqrt().add_(eps), value=-step_size)
p.mul_(1 - lr * weight_decay) # 解耦衰减:直接缩参数,不经过 v偏差修正是因为 m、v 从 0 开始,前几步的滑动平均偏小,除以 1 − β^t 把它拉回来。拿它和 torch.optim.AdamW 对比,在同一个二次函数上跑 100 步:
max |ours - torch| after 100 steps: 0.0008425712585449219
state tensors per param: ['m', 'v']两者接近但不完全相同,差异来自衰减的顺序:PyTorch 先衰减再做 Adam 更新,这份实现先更新再衰减,每步差一个 lr²·λ 量级的项。两种写法都常见,读代码时要留意。
现在可以把每个参数的字节数列出来:bf16 参数 2 + bf16 梯度 2 + fp32 master 4 + fp32 m 4 + fp32 v 4 = 16 字节。其中后三项合称优化器状态,占了 12 字节,是最大的一块。有的配置把 m、v 存成 bf16,总数变成 12 字节,读别人的数字前要先确认用的是哪种设定。激活另算,它随 batch 和序列长度增长,怎么估算见 资源核算与 Scaling Laws。
混合精度训练里,参数已经有一份 bf16 副本参与前向和反向,为什么还要再存一份 fp32 的 master weights?
bf16 只有 7 位尾数,1.0 附近的间距是 0.0078,减 0.001 会被舍入回 1.0;fp32 的 master 能把这些小更新累积起来。
前向和反向照样用 bf16,Tensor Core 内部用 fp32 累加,结果精度有保障;fp32 副本让 checkpoint 更大而不是更小;状态的精度和能否相乘无关。
训练循环与 checkpoint
有了模型和优化器,一个训练步是固定的几行:
for it in range(start_iter, max_iters):
lr = get_lr_cosine_schedule(it, max_lr, min_lr, warmup_iters, cosine_iters)
for g in opt.param_groups: g["lr"] = lr
x, y = get_batch(data, batch_size, context_len, device)
loss = cross_entropy(model(x).reshape(-1, vocab), y.reshape(-1))
opt.zero_grad(set_to_none=True)
loss.backward()
gradient_clipping(model.parameters(), max_norm=1.0) # 防止偶发的大梯度把参数打飞
opt.step()
if it % ckpt_every == 0:
save_checkpoint(model, opt, it, path)(示意,函数名沿用 CS336 Assignment 1。)学习率先线性 warmup:训练刚开始时 Adam 的 v 估计还很不准,步子太大容易发散;之后按余弦从 max_lr 降到 min_lr。梯度裁剪把所有参数梯度拼成一个向量,L2 范数超过阈值就整体等比缩小,方向不变。batch 太大放不下时用梯度累积:连续做 k 次前向反向、梯度自动累加,再做一次 step,效果等价于 k 倍的 batch(loss 要除以 k)。
checkpoint 必须保存的不只是模型权重:还有优化器状态(m、v、步数 t)和当前迭代号,最好还有随机数状态和数据读取位置。只存权重的话,恢复后 m、v 从 0 开始,偏差修正从 t = 1 重来,相当于在训练中途重新做了一次 warmup,loss 曲线会出现一个台阶。大规模训练里这不是边角问题:Llama 3 405B 的 54 天预训练中断了几百次,其中约 78% 来自确认或疑似的硬件故障,checkpoint 和自动恢复是基础设施的一部分。
激活太大时还有一个单卡就能用的手段:activation checkpointing,前向只保存每层的输入,反向时把这一层重新算一遍得到中间激活。多花约一次前向的计算,换来激活显存从”每层所有中间量”降到”每层一个输入”。它和下面所有的切分方法都可以叠加。
卡和卡怎么说话:集合通信
多卡之间的数据流动不用一条条点对点消息描述,而用几个集合通信原语,一次说清所有设备之间的通信模式。设备叫 rank,总数叫 world size。用 PyTorch 的 gloo 后端在 CPU 上起 4 个进程,看最常用的三个:
import os, torch, torch.distributed as dist, torch.multiprocessing as mp
def run(rank, W):
os.environ["MASTER_ADDR"], os.environ["MASTER_PORT"] = "127.0.0.1", "29512"
dist.init_process_group("gloo", rank=rank, world_size=W) # GPU 上用 "nccl"
x = torch.arange(W, dtype=torch.float32) + rank
a = x.clone(); dist.all_reduce(a) # 默认求和,原地修改
part = torch.empty(1); dist.reduce_scatter_tensor(part, x)
full = torch.empty(W); dist.all_gather_into_tensor(full, part)
print(f"rank{rank} in={x.tolist()} all_reduce={a.tolist()} "
f"reduce_scatter={part.tolist()} all_gather={full.tolist()}", flush=True)
dist.destroy_process_group()
if __name__ == "__main__":
mp.spawn(run, args=(4,), nprocs=4)rank1 in=[1.0, 2.0, 3.0, 4.0] all_reduce=[6.0, 10.0, 14.0, 18.0] reduce_scatter=[10.0] all_gather=[6.0, 10.0, 14.0, 18.0]
rank0 in=[0.0, 1.0, 2.0, 3.0] all_reduce=[6.0, 10.0, 14.0, 18.0] reduce_scatter=[6.0] all_gather=[6.0, 10.0, 14.0, 18.0]
rank2 in=[2.0, 3.0, 4.0, 5.0] all_reduce=[6.0, 10.0, 14.0, 18.0] reduce_scatter=[14.0] all_gather=[6.0, 10.0, 14.0, 18.0]
rank3 in=[3.0, 4.0, 5.0, 6.0] all_reduce=[6.0, 10.0, 14.0, 18.0] reduce_scatter=[18.0] all_gather=[6.0, 10.0, 14.0, 18.0](还有两行 deprecation 警告,已省略;打印顺序乱是因为进程异步。)all-reduce 让每个 rank 都拿到全体之和;reduce-scatter 求和但每个 rank 只拿自己那一段(rank i 拿第 i 个位置的和);all-gather 把每个 rank 的一段拼起来发给所有人。最后一列说明了贯穿后文的等式:
all-reduce = reduce-scatter + all-gather另外还有 broadcast(一个 rank 发给所有人,比如加载初始 checkpoint)和 all-to-all(rank j 收到每个 rank 的第 j 段,相当于转置,MoE 路由用它)。
成本怎么算?NCCL 在带宽受限时常用 ring 算法:W 张卡连成环,数据切成 W 块,先走 W−1 步 reduce-scatter,再走 W−1 步 all-gather,每步每张卡发送 size / W。所以 all-reduce 每张卡发送 2 × size × (W−1)/W,W 大时接近 2 倍数据量,与卡数基本无关;reduce-scatter 或 all-gather 单独做是一半。小消息另当别论,几 KB 到几 MB 的消息主要被每步的启动延迟限制,所以 DDP 会把小梯度打包成 bucket(PyTorch 默认 25 MB)再发。
传多快取决于走哪条线。以 H100 SXM5 节点为例:节点内 8 张卡经 NVLink 4 / NVSwitch 互连,每卡 900 GB/s(双向合计,单向 450 GB/s),这已经比本卡 HBM 的 3.35 TB/s 慢好几倍;节点之间走 InfiniBand,NDR 每卡一块 400 Gb/s 的网卡约 50 GB/s,又慢一个数量级。B200 的 NVLink 5 是每卡 1.8 TB/s。NVLink 和 InfiniBand 都支持 RDMA,一张卡直接读写另一张卡的显存,不经过 CPU。节点内 vs 节点间的带宽落差,决定了后面每种并行该放在哪一层。
8 张卡做 ring all-reduce,张量大小 800 MB。每张卡一共要发送多少数据?
每张卡发送 2 × 800 × 7/8 = 1400 MB:reduce-scatter 和 all-gather 各 7 步,每步发 100 MB 的一块。
800 MB 忘了两个阶段;100 MB 是一步的量;6400 MB 是错把所有卡的发送量加在一起再乘 2 当成了每张卡的量。
数据并行与 ZeRO:按 batch 切,再把状态切碎
最简单的并行是数据并行(DDP):每张卡一份完整模型,batch 切成 W 份,每张卡算自己那份的梯度,然后对梯度做一次 all-reduce 取平均,大家用同样的梯度做同样的更新,参数始终一致。为什么平均各分片的梯度就等于全 batch 的梯度?因为 loss 是样本 loss 的平均,梯度是线性的:
import torch
torch.manual_seed(0)
W1 = torch.randn(64, 32, dtype=torch.float64, requires_grad=True)
X, Y = torch.randn(128, 64, dtype=torch.float64), torch.randn(128, 32, dtype=torch.float64)
def loss_fn(x, y): return ((torch.tanh(x @ W1) - y) ** 2).mean()
g_full, = torch.autograd.grad(loss_fn(X, Y), W1)
shards = [torch.autograd.grad(loss_fn(x, y), W1)[0] for x, y in zip(X.chunk(4), Y.chunk(4))]
print("DDP avg grad == full-batch grad:", torch.allclose(torch.stack(shards).mean(0), g_full))DDP avg grad == full-batch grad: True(前提是每个分片一样大;LayerNorm 这类逐样本的归一化不跨样本,所以不影响这个等式。)通信量是每步一次梯度 all-reduce,约 2 × 梯度字节,与 batch 无关。7B 模型、bf16 梯度 14 GB、8 卡时每卡发送约 24.5 GB,NVLink 单向 450 GB/s 下约 54 ms,跨节点 50 GB/s 下约 0.5 s。真正的 DDP 不等反向结束才通信:最后一层的梯度最先算完,一个 bucket 满了就异步发起 all-reduce,和前面层的反向计算重叠。
DDP 的问题在内存:每张卡都存全部 16 字节/参数,卡再多也不能训更大的模型。ZeRO 的想法是:既然 all-reduce = reduce-scatter + all-gather,那就别让每张卡都持有全部状态。
- ZeRO-1 切优化器状态。反向后对梯度做 reduce-scatter,每张卡只拿到自己负责那 1/W 参数的梯度之和,只更新这一片,再 all-gather 更新后的参数。通信总量还是 2 倍参数量,和 DDP 一样,所以应该总是开。
- ZeRO-2 再切梯度。反向时一层的梯度一算完就 reduce 给负责的卡,然后释放,通信量不变。
- ZeRO-3(PyTorch 叫 FSDP)连参数也切。前向每到一层先 all-gather 这层参数,算完释放;反向再 all-gather 一次,算完对梯度 reduce-scatter。通信变成 3 倍参数量(DDP 的 1.5 倍),靠”算第 k 层时预取第 k+1 层参数”藏在计算后面。
按 ZeRO 论文的例子(7.5B 参数、64 卡,优化器状态每参数 K = 12 字节)算每卡显存:
baseline 120.0 GB/GPU
ZeRO-1 31.4 GB/GPU
ZeRO-2 16.6 GB/GPU
ZeRO-3 1.9 GB/GPU只有 ZeRO-3 随卡数线性下降。但它切不掉激活:每张卡仍要对自己那份数据跑完整的前向,激活一点没少。另外,数据并行的 batch 也不能无限加:每卡至少要分到一些样本,而全局 batch 超过 critical batch size 后,再加样本对优化的帮助不如多走一步。
FSDP(ZeRO-3)和流水线并行都把参数分到了多张卡上。两者最本质的区别是什么?
FSDP 本质仍是数据并行:每层算之前把这层参数 all-gather 过来,用完就扔;流水线是模型并行,第 1 段的输出激活发给第 2 段。
第一项把两者说反了;FSDP 不传激活;FSDP 每层都有通信,只是可以和计算重叠。
张量并行与序列并行:按宽度切
当一层的矩阵本身太大,或者想让激活也随卡数下降,就要切进层的内部。张量并行(TP)利用的是矩阵乘可以拆成小矩阵乘。Megatron-LM 对 MLP 的切法:第一个矩阵 A 按列切,第二个矩阵 B 按行切。
D, F_ = 16, 64
x = torch.randn(8, D, dtype=torch.float64)
A, B = torch.randn(D, F_, dtype=torch.float64), torch.randn(F_, D, dtype=torch.float64)
ref = torch.nn.functional.gelu(x @ A) @ B
parts = [torch.nn.functional.gelu(x @ a) @ b # 每张卡:自己那几列 A、几行 B
for a, b in zip(A.chunk(4, dim=1), B.chunk(4, dim=0))]
print("sum of 4 partials == full MLP:", torch.allclose(sum(parts), ref))
print("partial shape", tuple(parts[0].shape))sum of 4 partials == full MLP: True
partial shape (8, 16)按列切 A 时,每张卡得到隐藏层的一部分列,GeLU 是逐元素的,可以直接在分片上做,中间不需要任何通信;按行切 B 后,每张卡得到一个完整形状的部分和,最后一次 all-reduce 加起来。attention 同理:按头切(每卡算 H/W 个头),输出投影按行切。所以每个 Transformer 层前向 2 次 all-reduce、反向 2 次,每次的大小是激活 b × s × h。这个通信在关键路径上,下一层要等它,很难重叠,量又大,所以 TP 只在节点内的 NVLink 域里用,通常 ≤ 8。注意 LayerNorm、softmax 这种要看完整隐藏维的运算不能在列分片上做,这正是 Megatron 把它们放在切分边界之外的原因。
TP 切掉了权重和大部分激活,但留下了一块:Korthikanti 等(2022)算出每层激活约为 sbh(34 + 5as/h) 字节(s 序列长、b micro-batch、h 隐藏维、a 头数),开 t 路 TP 后变成 sbh(10 + 24/t + 5as/(ht))。那个 10 来自 LayerNorm、dropout 和残差输入,它们在每张卡上都是完整的一份。这些都是沿序列逐位置的运算,于是序列并行(SP)沿序列轴把它们切开,进入 TP 区域前 all-gather,离开时 reduce-scatter,代替原来的 all-reduce,通信量不变。再对 attention 的平方项做选择性重算(就是 FlashAttention 做的事),激活降到 34sbh / t,完全随 t 线性下降。
在按列切的张量并行里,每张卡手上是隐藏维的一个分片。哪些运算可以直接在分片上做、不需要先通信?
逐元素运算只看自己这个位置,分片上算和完整张量上算结果一样。
LayerNorm 要整条隐藏维的均值和方差,softmax 要整条的最大值和总和,分片上只有一部分,必须先通信。
流水线并行:按层切
流水线并行(PP)把层切成 p 段(stage),每张卡一段,激活从前一段发给后一段,反向时梯度反着走。通信是点对点的 send / recv,每个边界只传 b × s × h 的激活,比 TP 每层 4 次 all-reduce 少得多,所以 PP 能放在慢的节点间链路上。
代价是 bubble。朴素地一次喂一整个 batch,任意时刻只有一张卡在算,p 张卡每张只有 1/p 的时间在工作。解法是把 batch 切成 m 个 micro-batch,第 1 段算完第一个就发出去、接着算第二个。最朴素的调度(GPipe:全部前向、再全部反向)下,bubble 占总时间的比例是 (p − 1) / (m + p − 1):
bubble (p-1)/(m+p-1): {1: 0.75, 4: 0.429, 8: 0.273, 32: 0.086}p = 4 时,m = 32 才能把空闲压到 9% 以下。(也有文献用”bubble 时间 / 有用计算时间” (p − 1)/m,分母不同,m 远大于 p 时两者接近。)所以 PP 吃 batch:它和数据并行争的是同一份全局 batch。
GPipe 还要同时存下全部 m 个 micro-batch 的激活。1F1B 调度在热身阶段做 p 个前向后,每做一个前向就紧跟一个反向,反向完就释放那份激活,bubble 不变,但每段最多同时存约 p 份激活,m 可以放心加大。interleaved 1F1B 让每张卡负责 v 个不相邻的小段,bubble 再除以 v,代价是通信多 v 倍。zero bubble 调度则利用反向的一个不对称:反向里”把梯度传给上一段”(B)有人在等,“算本层权重梯度”(W)没人等,先做 B、把 W 推迟到空闲时段,几乎能填满流水线。
p = 4 个 stage、m = 32 个 micro-batch,从 GPipe 换成 1F1B,主要改变了什么?
1F1B 改的是前向和反向的排列顺序:反向尽早做、激活尽早释放,空闲时间的总量和 GPipe 一样。
接近零 bubble 的是 zero bubble 调度;每个 micro-batch 的通信量没变;1F1B 仍然依赖 micro-batch。
长序列与 MoE:上下文并行和专家并行
剩下两个维度对应两种特殊的”宽”。上下文并行(CP,又叫 ring attention)把超长序列按 token 切到多张卡上,每张卡持有自己那段的 Q,K、V 的块沿环在卡间传递,每张卡用传来的 KV 块和自己的 Q 做分块 attention,用的正是 FlashAttention 里那个可合并的 online softmax 状态。它主要用在长上下文扩展阶段。
专家并行(EP)用于 MoE(路由机制见 注意力变体与 MoE):不切矩阵,而是把整个 expert 放到不同卡上,router 决定每个 token 去哪个 expert,用 all-to-all 把 token 发过去、算完再 all-to-all 发回来。和 TP 相比,EP 保留了每个 expert 完整的大矩阵,GPU 利用率更好;难点是 all-to-all 的目的地事先不知道,各卡负载可能不均,而且计算在等 token 到达,必须低延迟。DeepSeek 为此专门写了 DeepEP 通信库。attention 层没有 expert,用不了 EP,所以 Megatron 让 attention 和 MLP 用不同的并行配置:attention 走 TP/CP/DP,MoE 层走 EP 和专家内的 TP/DP。
把它们组合起来
每种方法都有短板,没有哪一种全面胜出:
| 方法 | 切什么 | 每步主要通信 | 能和计算重叠吗 | 放在哪层网络 |
|---|---|---|---|---|
| DDP / ZeRO-1/2 | batch | 梯度,约 2 倍参数量 | 能,藏在反向里 | 任意 |
| FSDP / ZeRO-3 | batch,参数也分片 | 参数 + 梯度,约 3 倍参数量 | 能,靠预取 | 节点间也可 |
| TP(+ SP) | 每层的宽度 | 每层 4 次激活大小的集合通信 | 很难,在关键路径上 | 节点内 NVLink |
| PP | 层 | 每边界每 micro-batch 一次激活 | 部分,靠调度 | 节点间 |
| CP | 序列 | KV 块沿环传递 | 部分 | 节点内或相邻 |
| EP | expert | 每个 MoE 层两次 all-to-all | 部分 | 节点内为主 |
经验规则可以压成三条。第一,先让模型放进显存:TP(和 EP)最多开到一台机器的卡数,通常 8;还放不下就跨节点加 PP,或者用 FSDP,看节点间带宽。第二,放下之后,剩下的卡全给数据并行(配合 ZeRO-1 切优化器状态)。第三,每卡分到的 batch 太小、通信盖不住时,用梯度累积换更大的 batch。每多开一个维度,就多一类集合通信、多一次同步、多一个出故障的地方,所以维度能少开就少开。
Llama 3 405B(dense 模型,所以没有 EP)的实际配置印证了这些规则:
| 阶段 | GPU 数 | TP | CP | PP | DP | 序列长度 | MFU |
|---|---|---|---|---|---|---|---|
| 初期 | 8,192 | 8 | 1 | 16 | 64 | 8,192 | 43% |
| 主预训练 | 16,384 | 8 | 1 | 16 | 128 | 8,192 | 41% |
| 长上下文 | 16,384 | 8 | 16 | 16 | 8 | 131,072 | 38% |
GPU 数 = TP × CP × PP × DP。维度从内到外按带宽需求排:TP 在最里层(同一台服务器),DP(实际是 FSDP)在最外层,因为它能异步预取参数、异步归约梯度,最能容忍延迟。长上下文阶段把 CP 加到 16、DP 降到 8,卡数不变。
一条贯穿全篇的取舍:同一份数据可以重算、可以存在本卡、也可以存在别的卡上用时再传。activation checkpointing 是前两者之间的取舍,ZeRO 和各种模型并行把第三种也加了进来。硬件会变快,模型总会变得更大,这三者之间的平衡点会一直移动。
用 1024 张 GPU(每节点 8 张)训练一个 dense 模型,模型要 TP 8 × PP 4 才放得下。哪些做法合理?
TP 每层都有阻塞式通信,只适合 NVLink;PP 跨节点;剩下的卡交给最好扩展的数据并行;batch 不够时梯度累积能让通信更容易被盖住。
跨节点做 TP,每层 4 次激活大小的 all-reduce 都要走慢一个数量级的 InfiniBand,吞吐会大幅下降。
记住这三件事
- 混合精度 + AdamW 下每个参数约 16 字节,其中优化器状态占 12 字节;ZeRO 靠 all-reduce = reduce-scatter + all-gather,在几乎不加通信的前提下把这些状态切到各卡。
- 数据并行传梯度、可以藏在反向后面;张量并行每层都传激活、只能放在 NVLink 节点内;流水线只在相邻段传激活、能跨慢链路,但要用大 batch 填 bubble。
- 组合的顺序是:先用 TP(≤ 8)和 PP 让模型放进显存,再把剩下的卡全给数据并行;checkpoint 要连优化器状态和迭代号一起存。
延伸阅读
- Stanford CS336 Lecture 7–8(并行 I / II)课件与代码,Assignment 1–2:https://github.com/stanford-cs336
- Rajbhandari et al., ZeRO: Memory Optimizations Toward Training Trillion Parameter Models(arXiv 1910.02054)
- Narayanan et al., Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM(arXiv 2104.04473);Korthikanti et al., Reducing Activation Recomputation in Large Transformer Models(arXiv 2205.05198)
- Loshchilov & Hutter, Decoupled Weight Decay Regularization(arXiv 1711.05101)
- How to Scale Your Model(JAX 团队):https://jax-ml.github.io/scaling-book/
- Llama 3 Herd of Models 技术报告(arXiv 2407.21783),第 3.3 节训练基础设施