Skip to content
alarik.me
Go back

LLM 通信原语详解(一):从 Send/Recv 到 All-to-All

Updated:

人人都说过「DDP 会 all-reduce 梯度」「MoE 的瓶颈在 all-to-all」,但从这句话到真正算清一次通信要搬多少字节、为什么 ZeRO-3 用 AG+RS 而不是 AR,中间隔着一层没人细讲的词汇表。这篇把这层词汇表补齐:点对点 + 八个集合通信原语,每个讲清四件事——语义、每卡通信量、torch.distributed 里怎么写、在 LLM 训练/推理框架里长在哪。算法内部(ring/tree 逐步推演、NCCL 机制、重叠调度)留给下一篇。所有代码在 PyTorch 2.9、gloo 后端、4 进程下实测通过——CPU 就能跑,不需要 GPU。

Table of contents

Open Table of contents

一、预备:两条通信路径

设集群有 MM 个进程(rank),每个 rank 绑一张卡。跨卡数据移动只有两条路径:

工程上这两条路径都主要由通信库代理——NVIDIA 卡上是 NCCL(AMD 对应 RCCL),PyTorch 的 dist.init_process_group(backend="nccl") 底下就是它。开发调试时用 CPU 后端 gloo 也完全够:原语语义一致,本文所有示例就是这么跑的。NCCL 本身只提供原语,不提供并行策略;所有并行策略最终都被编译成这九个词的序列。这也是读分布式训练代码的方法:先把框架 API 还原成原语序列,再算每个原语的字节账。

记号:VV = 参与操作的逻辑张量总大小(字节),WW = 链路有效带宽,每卡起始持有 V/MV/M(分片)或 VV(副本)。

二、Send/Recv:点对点

最简单的原语:rank ii 把一块数据发给 rank jj,仅此而已。用 tag 区分同一对 rank 之间的多条逻辑流:

act = compute(x) # 本 stage 的输出激活
if rank > 0:
dist.send(act, dst=rank - 1, tag=10) # 激活发下游
g_act = torch.empty_like(act)
if rank < world_size - 1:
dist.recv(g_act, src=rank + 1, tag=20) # 梯度从下游回来

同步版 send/recv 会阻塞到对端就绪;流水线实现里用异步版 isend/irecv 拿到 handle,把传输和计算重叠起来(1F1B 调度的本质就是管理这些 in-flight 的 handle)。

它承载了两大 LLM 场景:

流水线并行(PP)。模型按层切成 MM 段,每段住一个 rank。microbatch 的激活值沿着流水线向下传、反向时梯度往回传——相邻 stage 之间全是 Send/Recv。1F1B 等调度只是在安排这些点对点发送的时序,通信本身没有变。

PD 分离推理。Prefill 和 Decode 部署在不同机器上时,prefill 结束后要把该请求的 KV cache 搬去 decode 侧。这个传输不走 NCCL 集合通信,而是点对点 RDMA——Mooncake、vLLM/SGLang 的 disagg 传输层都是这个思路:KV 是「这一个请求的」数据,只对固定的 decode 实例有意义,集合通信反而低效。

点对点的好处是精确:发多少、发给谁、什么时候发,全部可控。代价是所有编排逻辑(路由、分块、同步)都要自己写——这正是集合通信存在的原因。

三、一对多与多对一:Broadcast / Scatter / Gather / Reduce

这四个原语共享一个结构:有一个特殊的 root rank,通信以它为中心。

Broadcast、Scatter、Gather、Reduce 的 before/after 状态

# Broadcast:root(src=0) 的值覆盖所有卡 —— 权重/超参下发
buf = load_checkpoint() if rank == 0 else torch.empty_like(shape)
dist.broadcast(buf, src=0) # 之后所有卡的 buf 完全一致
# Scatter:root 把完整张量按 rank 切片分发(scatter_list 只在 root 上有效)
mine = torch.empty(1, dtype=torch.float64)
scatter_list = [torch.tensor([float(i)], dtype=torch.float64)
for i in range(world_size)] if rank == 0 else None
dist.scatter(mine, scatter_list=scatter_list, src=0) # rank i 拿到第 i 片
# Gather:Scatter 的逆,各卡的份收拢到 root 拼接
gather_list = [torch.empty(1, dtype=torch.float64)
for _ in range(world_size)] if rank == 0 else None
dist.gather(mine, gather_list=gather_list, dst=0)
# Reduce:归约(默认 sum)后只落 root —— loss 归集
total = mine.clone()
dist.reduce(total, dst=0) # 只有 rank 0 拿到总和

四个原语的 root 都是瓶颈侧:root 需要收发 M−1MV\frac{M-1}{M}V,非 root 只收发 VM\frac{V}{M}。环形实现下每卡总发送量都不超过 ≈V\approx V——和全员原语同量级,但结果只有一个 rank 拿到(Reduce/Gather)或只有一个 rank 出发(Broadcast/Scatter)。

broadcast 在工程里最常见的出场是集群初始化:rank 0 从 checkpoint 恢复(或随机初始化)一份权重,广播给其他卡,保证所有副本从同一起点出发——ZeRO/FSDP 里每个参数分片的 owner 也用它对 padding、形状元数据达成一致。scatter/gather/reduce 在训练主循环里戏份不多(数据下发走 DataLoader 的 shard 采样,日志走 all_gather 居多),但它们是理解下一节全员原语的最好垫脚石:把 root 的特权去掉、让所有 rank 都拿到结果,就是 All- 系列。

四、全员参与:All-Gather / Reduce-Scatter / All-Reduce

现代并行策略的主力原语。共同点:没有 root,MM 个 rank 对称参与,结束时各自拿到自己需要的那份结果。

All-Gather、Reduce-Scatter、All-Reduce、All-to-All 的 before/after 状态

4.1 All-Gather

每卡持有 V/MV/M 分片,结束后每卡持有完整 VV。「只拼不加」——数据原样搬运。

shard = param_shard() # 本卡持有 1/M
full = torch.empty(world_size * shard.numel(), dtype=shard.dtype)
dist.all_gather_into_tensor(full, shard) # 拼回完整参数

注意 API 的演化:老接口 all_gather(tensor_list, tensor) 要你先建好一个装 Tensor 的 list;新接口 all_gather_into_tensor(out, in) 直接写进一块预分配的连续显存——FSDP 里参数聚合用的是后者,省一次拷贝和一堆小对象。

LLM 场景:ZeRO-3 / FSDP 的前向取参。参数平时按 V/MV/M 分片驻留,算到某层前把这层参数 AG 出来,算完即弃;Megatron 序列并行里激活进 TP 区域前的拼接也是 AG。

4.2 Reduce-Scatter 与前后向对偶

每卡持有 partial 值(各算各的、还没归约),结束后每卡持有归约结果的一个 V/MV/M 分片。「只加不拼」。

grad_shard = torch.empty(grad_flat.numel() // world_size, dtype=grad_flat.dtype)
dist.reduce_scatter_tensor(grad_shard, grad_flat) # 归约+分片一步到位
grad_shard /= world_size # 平均 —— FSDP 在这里就地更新

LLM 场景:ZeRO-2 梯度同步、FSDP 反向——梯度算出来是 partial 的,RS 完每卡只剩自己负责那 1/M1/M 的均值梯度,直接就地更新,全量梯度从不物化。

Note

AG 和 RS 互为前后向对偶:前向用 AG 的算子,反向梯度流过去就是 RS(各 rank 的梯度要累加再分发回分片);反之亦然。Scatter↔Gather、Broadcast↔Reduce 同样互为对偶;AR 和 A2A 的反向是自身。读 Megatron 论文里的通信模式时,这个对偶关系能让你只看前向就推出反向。

4.3 All-Reduce = Reduce-Scatter + All-Gather

AR 的语义一句话:归约 + 全员拿全量。关键洞察是它不是原子操作,而是两个半步的组合:

All-Reduce = Reduce-Scatter + All-Gather 的分解

All-Reduce(x)≡All-Gather(Reduce-Scatter(x))\text{All-Reduce}(x) \equiv \text{All-Gather}(\text{Reduce-Scatter}(x))

这句恒等式可以当场用代码验证:

x = torch.arange(2 * world_size, dtype=torch.float64) + 100 * rank # 各卡 partial
shard = torch.empty(2, dtype=torch.float64)
dist.reduce_scatter_tensor(shard, x) # 半步 1:RS
via_two_step = torch.empty(2 * world_size, dtype=torch.float64)
dist.all_gather_into_tensor(via_two_step, shard) # 半步 2:AG
direct = x.clone()
dist.all_reduce(direct) # 直接 AR
assert torch.equal(via_two_step, direct) # AG(RS(x)) == AR(x) ✓

字节账:RS 每卡搬 ≈V\approx V,AG 再搬 ≈V\approx V,AR 总共 ≈2V\approx 2V。这个恒等式是 ZeRO/FSDP 整个设计空间的起点——把 AR 拆开,两个半步就能在训练 step 的不同时刻分别执行:RS 在拿到 partial 数据时立刻做,AG 推迟到真正要算全量时再做;甚至有些地方数据可以一直保持分片,AG 整个省掉。

LLM 场景:DDP 梯度同步、Tensor Parallel 每层前向的 g 投影和反向的 o 梯度。

一个数量级感受:7B 模型 bf16 梯度 V=14V = 14 GB,32 卡 AR 每卡搬 2⋅3132⋅14≈272\cdot\frac{31}{32}\cdot 14 \approx 27 GB,200 GB/s 有效带宽下 ≈136\approx 136 ms——每步训练凭空多出一百多毫秒,这就是梯度 bucketing 和通信重叠存在的原因。

环形算法下 AR 需要 2(M−1)2(M-1) 步,每步每卡传 V/MV/M。按 α-β 模型:

TAR≈2(M−1) α⏟延迟项+2(M−1)M⋅VW⏟带宽项→2V/WT_{AR} \approx \underbrace{2(M-1)\,\alpha}_{\text{延迟项}} + \underbrace{\frac{2(M-1)}{M}\cdot\frac{V}{W}}_{\text{带宽项} \to 2V/W}

M→∞M \to \infty 时带宽项收敛到 2V/W2V/W,与卡数无关——这是 ring all-reduce 能撑起大规模训练的根本性质。MM 大、VV 小时延迟项占主导,换 tree 算法(2log⁡M2\log M 步)更划算,NCCL 按消息大小自动切换。

五、All-to-All:分布式转置

A2A 和前三个全员原语不同族:它不归约也不收集全量,而是重排。每卡持有一份按「目标」切好的数据(第 jj 块发给 rank jj),结束后换成按「来源」收好的数据(第 ii 块来自 rank ii)——整个矩阵做了一次转置(见上图右下面板)。

等长的 A2A 一个函数搞定:

send = stack([块_for_rank_0, 块_for_rank_1, ...]) # 第 j 块发给 rank j
recv = torch.empty_like(send)
dist.all_to_all_single(recv, send) # 转置后: recv[i] 来自 rank i

但 MoE 真实的难点是变长(ragged):每个 token 去哪个专家由 router 运行时决定,各卡收发量天然不均。NCCL/all_to_all_single 只接受等长块,所以工程上是三步走——先 A2A 交换元数据,再 padding 到统一长度做等长 A2A,最后按元数据剥掉 padding:

# ① 元数据交换:发往各卡的 token 数(各卡不同 → 先同步计数)
counts = torch.bincount(router, minlength=world_size) # 本卡发往各卡的量
in_counts = torch.empty(world_size, dtype=torch.long)
dist.all_to_all_single(in_counts, counts) # 收到: 各卡发给我的量
# ② padding 到统一容量 cap = max(counts),做等长 A2A(dispatch)
cap = int(counts.max() 全局同步)
send = torch.zeros(world_size, cap, d) # 按目标分组 + 补零
for j in range(world_size):
send[j, :counts[j]] = tokens[router == j]
recv = torch.empty_like(send)
dist.all_to_all_single(recv, send) # 现在 recv[j] = 来自卡 j
# ③ 专家计算后原路转置回去(combine),按 in_counts 剥掉 padding 还原 token 顺序

LLM 场景首推 MoE 的 expert dispatch/combine:dispatch A2A 把 token 从「按所在卡分组」转置成「按专家分组」,专家算完,combine A2A 转置回去,残差相加,继续下一层。

字节账:设每卡激活 BB(自己的 token 数 × hidden × dtype 字节),每卡发送 M−1MB\frac{M-1}{M}B,收发同量。8 卡、每卡 4096 token、hidden 4096、bf16:B≈34B \approx 34 MB,每次 A2A 发 ≈29\approx 29 MB,一层 dispatch+combine 共 ≈58\approx 58 MB,60 层就是每卡 ~3.4 GB/step——MoE 训练通信常常是主导成本,此言不虚。

A2A 的两个特殊难点:

第二场景:DeepSpeed-Ulysses 式序列并行——attention 头分布在各卡,用两次 A2A 把序列维分片转置成头维分片。AG/RS 是「复制/归约」型切分,A2A 是「交换」型切分,MoE 和 Ulysses 都属于后者。

六、通信组:同一集群,多套并行各玩各的

真实训练同时叠加 TP × DP × PP,一次 init_process_group 建的是世界组,各维并行用 new_group 切出子组,集合通信只在子组内发生:

tp_size = 2
tp_group = dist.new_group(ranks_for_this_tp_column) # 如 [0,1] 和 [2,3]
dp_group = dist.new_group(ranks_for_this_dp_row) # 如 [0,2] 和 [1,3]
x = torch.full((2,), float(rank))
t, d = x.clone(), x.clone()
dist.all_reduce(t, group=tp_group) # 只和 TP 组内的邻居归约
dist.all_reduce(d, group=dp_group) # 同一张量,DP 组内另一个结果

4 卡实例:R0 在 TP 组 [0,1] 里归约得 1.0,在 DP 组 [0,2] 里归约得 2.0——同一个 all_reduce 调用,group 参数决定它属于哪条并行维。Megatron-LM 初始化时就把 tp/dp/pp 三套 group 全建好,层里的每个集合调用都显式传 group=。

七、工程视角:这些调用长在框架的哪里

知道原语语义之后,读框架源码的钥匙就一句话:每个并行策略 = 把策略特有的张量切分,翻译成固定的原语序列。

八、亲手跑一遍

把上面的片段串成一个完整脚本(约 100 行,无外部依赖),覆盖六个实验:PP 双向点对点、root 族四件套、AG∘RS ≡ AR 恒等式、ragged MoE dispatch/combine 往返、TP/DP 双通信组、DDP 与 Zero-2 的等价更新:

"""comm_primitives_demo.py — torch.distributed 通信原语可运行演示
CPU + gloo 即可运行(无需 GPU):
torchrun --nproc-per-node=4 comm_primitives_demo.py
"""
import os
import torch
import torch.distributed as dist
R = int(os.environ["RANK"])
W = int(os.environ["WORLD_SIZE"])
dist.init_process_group("gloo")
torch.manual_seed(0) # 所有 rank 同种子 → 同初始化
def log(msg):
if R == 0:
print(f"[R0] {msg}", flush=True)
def head(s):
if R == 0:
print(f"\n=== {s} ===", flush=True)
# ---------- 1. Send/Recv: 模拟 PP 相邻 stage ----------
head("1. Send/Recv — 流水线: 激活向右, 梯度向左")
act = torch.tensor([float(R + 1), 0.0])
recv_l, recv_r = torch.zeros(2), torch.zeros(2)
if R > 0:
dist.recv(recv_l, src=R - 1, tag=10) # 收上游激活
if R < W - 1:
dist.send(act, dst=R + 1, tag=10) # 激活发下游
if R < W - 1:
dist.recv(recv_r, src=R + 1, tag=20) # 收下游梯度
if R > 0:
dist.send(torch.tensor([1.0, 1.0]), dst=R - 1, tag=20) # 梯度发上游
log(f"stage{R}: 上游激活={recv_l.tolist()} 下游梯度={recv_r.tolist()}")
# ---------- 2. root 族 ----------
head("2. Broadcast / Scatter / Gather / Reduce")
b = torch.arange(4, dtype=torch.float64) + R * 100
dist.broadcast(b, src=0)
assert torch.equal(b, torch.arange(4, dtype=torch.float64))
mine = torch.empty(1, dtype=torch.float64)
src = [torch.tensor([float(i)], dtype=torch.float64) for i in range(W)] if R == 0 else None
dist.scatter(mine, scatter_list=src, src=0) # rank i 拿到第 i 片
assert mine.item() == R
gathered = [torch.empty(1, dtype=torch.float64) for _ in range(W)] if R == 0 else None
dist.gather(mine, gather_list=gathered, dst=0)
if R == 0:
assert [g.item() for g in gathered] == list(range(W))
red = mine.clone()
dist.reduce(red, dst=0)
if R == 0:
assert red.item() == sum(range(W))
log("broadcast/scatter/gather/reduce 断言全部通过")
# ---------- 3. 全员族 + AR = RS + AG ----------
head("3. All-Gather / Reduce-Scatter / All-Reduce")
inp = torch.arange(2 * W, dtype=torch.float64) + 100 * R # 每卡一份 partial
shard = torch.empty(2, dtype=torch.float64)
dist.reduce_scatter_tensor(shard, inp) # RS: 各持 1/W 归约分片
full = torch.empty(2 * W, dtype=torch.float64)
dist.all_gather_into_tensor(full, shard) # AG: 分片拼回完整
ref = inp.clone()
dist.all_reduce(ref) # 直接 AR 做对照
assert torch.equal(full, ref) # AG(RS(x)) == AR(x)
log(f"RS 分片={shard.tolist()}, 恒等式 AG(RS(x)) == AR(x) 成立 ✓")
# ---------- 4. All-to-All: mini MoE dispatch/combine ----------
head("4. All-to-All — MoE dispatch/combine (ragged 变长)")
g = torch.Generator().manual_seed(100 + R)
n_tok = 2
tok = torch.randn(n_tok, 2, generator=g) # 本卡 token
router = torch.randint(0, W, (n_tok,), generator=g) # token → 专家 → 卡
counts = torch.bincount(router, minlength=W) # 发往各卡的量(不均!)
in_counts = torch.empty(W, dtype=torch.long)
dist.all_to_all_single(in_counts, counts) # ① 元数据交换
cap_t = torch.tensor([int(counts.max())], dtype=torch.long)
dist.all_reduce(cap_t, op=dist.ReduceOp.MAX) # 全局最大容量
cap = int(cap_t)
send = torch.zeros(W, cap, 2) # ② 按目标分组 + padding
for e in range(W):
rows = tok[router == e]
send[e, : rows.shape[0]] = rows
recv = torch.zeros(W, cap, 2)
dist.all_to_all_single(recv, send) # 等长 A2A
send2 = torch.zeros(W, cap, 2) # ③ 本地"专家"= *10, 转置回去
for j in range(W):
send2[j, : int(in_counts[j])] = recv[j, : int(in_counts[j])] * 10
recv2 = torch.zeros(W, cap, 2)
dist.all_to_all_single(recv2, send2)
final = torch.empty(n_tok, 2) # ④ 按 router 还原顺序
for e in range(W):
idx = (router == e).nonzero().squeeze(1)
final[idx] = recv2[e, : len(idx)]
assert torch.allclose(final, tok * 10)
log(f"router={router.tolist()} 发送计数={counts.tolist()} (ragged), "
f"dispatch→expert→combine 往返后 == tok*10 ✓")
# ---------- 5. new_group: TP × DP 子组 ----------
head("5. new_group — 同一集群, TP/DP 各自的通信组")
tp_size = 2
tp_g = [dist.new_group([0, 1]), dist.new_group([2, 3])]
dp_g = [dist.new_group([0, 2]), dist.new_group([1, 3])]
tp, dp = tp_g[R // tp_size], dp_g[R % tp_size]
a = torch.full((2,), float(R))
t, d = a.clone(), a.clone()
dist.all_reduce(t, group=tp)
dist.all_reduce(d, group=dp)
log(f"TP 组内和={t.tolist()} DP 组内和={d.tolist()} (同一张量, 两个组两个结果)")
# ---------- 6. DDP vs Zero-2: 同一笔 2V 通信账的两种花法 ----------
head("6. DDP vs Zero-2 — AR 的两种花法")
lin = torch.nn.Linear(8, 8, bias=False) # 同初始化
gd = torch.Generator().manual_seed(1000 + R)
x = torch.randn(2, 8, generator=gd) # 各卡不同数据(DP)
lin(x).pow(2).sum().backward()
lr = 0.1
g = lin.weight.grad.clone() # DDP: 梯度 AR + /W
dist.all_reduce(g)
g /= W
p_ddp = lin.weight.data - lr * g
flat = lin.weight.grad.reshape(-1) # Zero-2: RS 分片
n = flat.numel()
shard = torch.empty(n // W)
dist.reduce_scatter_tensor(shard, flat)
shard /= W
p_local = lin.weight.data.reshape(-1)[R * n // W : (R + 1) * n // W] - lr * shard
p_full = torch.empty(n)
dist.all_gather_into_tensor(p_full, p_local) # 参数 AG 重建
assert torch.allclose(p_full, p_ddp.reshape(-1))
log(f"64 参数: DDP 每卡常驻 64 梯度+64 优化器态; Zero-2 每卡只持 {n // W}; "
f"两条路径更新结果一致 ✓ (通信量同为 2V)")
dist.destroy_process_group()

实测输出(PyTorch 2.9.1、gloo、torchrun --nproc-per-node=4):

=== 1. Send/Recv — 流水线: 激活向右, 梯度向左 ===
[R0] stage0: 上游激活=[0.0, 0.0] 下游梯度=[1.0, 1.0]
=== 2. Broadcast / Scatter / Gather / Reduce ===
[R0] broadcast/scatter/gather/reduce 断言全部通过
=== 3. All-Gather / Reduce-Scatter / All-Reduce ===
[R0] RS 分片=[600.0, 604.0], 恒等式 AG(RS(x)) == AR(x) 成立 ✓
=== 4. All-to-All — MoE dispatch/combine (ragged 变长) ===
[R0] router=[2, 0] 发送计数=[1, 0, 1, 0] (ragged), dispatch→expert→combine 往返后 == tok*10 ✓
=== 5. new_group — 同一集群, TP/DP 各自的通信组 ===
[R0] TP 组内和=[1.0, 1.0] DP 组内和=[2.0, 2.0] (同一张量, 两个组两个结果)
=== 6. DDP vs Zero-2 — AR 的两种花法 ===
[R0] 64 参数: DDP 每卡常驻 64 梯度+64 优化器态; Zero-2 每卡只持 16; 两条路径更新结果一致 ✓ (通信量同为 2V)

实验 6 值得多看一眼:DDP 和 Zero-2 走的显式路径完全不同(AR vs RS+AG),但每卡通信量同为 2V2V,最终参数也逐位一致——差的只是梯度和优化器状态的驻留方式(DDP 每卡常驻全量,Zero-2 只驻 1/M1/M 分片)。这就是「ZeRO-2 相对 DDP 免费省显存」的代码级证明。

九、成本速查与场景对照

原语起始持量每卡发送量结束持量LLM 典型场景
Send/Recv任意DD—PP 激活/梯度传递、PD 分离 KV 传输(RDMA)
Broadcastroot: VV≈V\approx V(root)VV权重初始化、超参下发
Scatterroot: VV≈V\approx V(root)V/MV/M数据切分下发
GatherV/MV/MV/MV/Mroot: VV日志/指标聚合
ReduceVV(partial)≈V\approx Vroot: VVloss 归集
All-GatherV/MV/M≈V\approx VVVZeRO-3 取参、SP 进 TP 区
Reduce-ScatterVV(partial)≈V\approx VV/MV/MZeRO-2 梯度、SP 出 TP 区
All-ReduceVV(partial)≈2V\approx 2VVVDDP 梯度同步、TP 层内同步
All-to-AllBB(自己的份)≈B\approx B≈B\approx BMoE dispatch/combine、Ulysses SP

反过来查——每个并行策略用到什么:

并行策略通信原语
DDP反向 AR 梯度(bucket 化 + 与反向重叠)
ZeRO-1/2 (FSDP)梯度 RS + 更新后参数 AG
ZeRO-3 (FSDP full)前向/反向参数 AG + 梯度 RS,≈3V\approx 3V
Tensor Parallel每层 2× AR(或 SP 改造后的 AG+RS)
Sequence Parallel进 TP 区 AG、出 TP 区 RS(Megatron);或 2× A2A(Ulysses)
Pipeline Parallel相邻 stage 点对点 Send/Recv
Expert Parallel (MoE)每层 2× 变长 A2A

十、实践要点

Megatron 序列并行的账。TP 每层两个 AR 共 4V4V(前向 g + 反向 o 各一次,正反向合计),SP 把它们换成 AG+RS 组合——总字节不变,换来两个好处:LN/dropout 的激活也按序列维分片(激活显存 ÷ M),且两个半步的集合通信更容易和前后 GEMM 重叠。

ZeRO 的账。论文口径:Z1/Z2 通信量与 DDP 相当(≈2V\approx 2V),Z3 为 ≈3V\approx 3V(前向 AG + 反向重取 AG + 梯度 RS),贵 50%。买的是显存:参数、梯度、优化器状态全部 ÷ M。

算法与协议由库自动选。NCCL 按 message size 和拓扑在 ring(大消息、带宽最优)/ tree(小消息、2log⁡M2\log M 步延迟最优)/ NVLS(NVSwitch 网内归约)之间切换,协议层另有 Simple/LL/LL128 三档。vLLM 的 custom_all_reduce 是单机 NVLink 域内小消息的 one-shot 实现;PyTorch symmetric memory 提供同类能力,RL roll out 的 weight sync、eager 推理的小张量同步受益明显。手动设 NCCL_ALGO 只该用于 A/B 实验。

常见陷阱(前两条本文示例都真实踩到/触发过):

十一、小结与下一篇

九个原语、三句话记全:root 族(Broadcast/Scatter/Gather/Reduce)围绕一个中心,≈V\approx V 的流量,现代 LLM 负载里戏份不多;全员复制族(AG/RS/AR)是并行策略的主体,AG 只拼、RS 只加、AR = RS + AG ≈2V\approx 2V;A2A 独立成族,做转置不做归约,MoE 时代从配角升为主角。

下一篇深入算法内部:ring 的逐步推演与为什么带宽项与 MM 无关、tree/NVLS 的适用边界、NCCL 的 channel 与协议机制、通信-计算重叠的具体手法、DeepEP 的变长 A2A。

参考资料


Share this post:

Next Post
从 PPO 到 GRPO:LLM 强化学习的原理与 PyTorch 实现