总览:六种通信原语
分布式训练中,GPU 之间的数据交换全部依赖 NCCL 集合通信。理解这六种操作是读懂所有并行策略代码的基础。
| 操作 | 数据流向 | 每 GPU 发送量 (Ring) | Megatron 中的典型用途 |
|---|---|---|---|
| Broadcast | 一对多 | \(\frac{N-1}{N} \cdot D\) | TP rank 0 广播数据给组内其他 rank |
| AllReduce | 多对多(归约) | \(\frac{2(N-1)}{N} \cdot D\) | TP 前向/反向的梯度聚合、DP 梯度同步 |
| AllGather | 多对多(拼接) | \(\frac{N-1}{N} \cdot D\) | SP 还原完整序列、分布式优化器参数收集 |
| ReduceScatter | 多对多(归约+切分) | \(\frac{N-1}{N} \cdot D\) | SP 反向梯度分发、分布式优化器梯度切片 |
| P2P Send/Recv | 点对点 | D(仅两端) | PP 流水线相邻 stage 间传递激活值/梯度 |
| AllToAll | 多对多(交换) | \(\frac{N-1}{N} \cdot D\) | MoE token 路由、Context Parallel 切换 |
N = 组内 GPU 数,D = 每个 GPU 上参与通信的数据量。当 N 较大时 \(\frac{N-1}{N} \to 1\),可近似为 D。注意 AllReduce = ReduceScatter + AllGather,所以通信量是其他操作的 2 倍。
Broadcast:一对多广播
| 操作前 | 操作后 | |
|---|---|---|
| rank 0 (src)[A B] | → | rank 0[A B] |
| rank 1空 | → | rank 1[A B] |
| rank 2空 | → | rank 2[A B] |
| rank 3空 | → | rank 3[A B] |
语义:一个 rank 持有数据(大小 D),复制给组内所有其他 rank。
通信量:源 rank 发送 \(\frac{N-1}{N} \cdot D\)(ring),其他 rank 各接收 D。虽然只有一个 rank 产生数据,但 ring 实现让中转负载均摊到每个 rank。
# ① 先广播 tensor 的 shape 信息
sizes_cuda = torch.tensor(sizes, dtype=torch.long, device='cuda')
torch.distributed.broadcast(sizes_cuda, group_ranks[0], group=tp_group)
# ② 把所有 tensor 拼成一维,一次性广播(减少通信次数)
flatten_data = torch.cat(
[data[key].cuda().contiguous().view(-1) for key in keys], dim=0
)
torch.distributed.broadcast(flatten_data, group_ranks[0], group=tp_group)
AllReduce:全局归约
| 操作前 | 操作后 | |
|---|---|---|
| rank 0[1, 2] | → | rank 0[10, 20] |
| rank 1[2, 3] | → | rank 1[10, 20] |
| rank 2[3, 5] | → | rank 2[10, 20] |
| rank 3[4, 10] | → | rank 3[10, 20] |
语义:每个 rank 持有大小为 D 的数据,对所有 rank 的数据做逐元素归约(通常是 SUM),结果每个 rank 都有一份完整拷贝。
通信量:每 GPU 发送 \(\frac{2(N-1)}{N} \cdot D \approx 2D\)。是所有集合操作中最贵的,因为它等价于 ReduceScatter + AllGather,通信量是单个操作的 2 倍。
# Row Parallel Linear 的前向输出需要 AllReduce
# 每个 TP rank 算出部分结果 (Y_i = X * W_i),求和得到完整输出 (Y = ΣY_i)
def _reduce(input_):
group = get_tensor_model_parallel_group()
torch.distributed.all_reduce(input_.contiguous(), group=group)
# 同一 DP 组内的 rank 持有相同模型,处理不同数据
# 反向后各自的梯度不同,AllReduce 求平均
torch.distributed.all_reduce(
bucket.grad_data, op=reduce_op, group=communication_group, async_op=async_op
)
Ring AllReduce 算法推导 可选
AllGather:全局收集
| 操作前 | 操作后 | |
|---|---|---|
| rank 0[A] | → | rank 0[A B C D] |
| rank 1[B] | → | rank 1[A B C D] |
| rank 2[C] | → | rank 2[A B C D] |
| rank 3[D] | → | rank 3[A B C D] |
语义:每个 rank 持有数据的一个分片(大小 D/N),AllGather 后每个 rank 都得到所有分片的拼接(大小 D)。
通信量:每 GPU 发送 \(\frac{N-1}{N} \cdot D \approx D\)。每步沿 ring 传 \(\frac{D}{N}\),共 \(N\!-\!1\) 步。注意输入是 \(\frac{D}{N}\) 但通信量按总数据 \(D\) 算,因为自己的那份不用传。
# SP 下,每个 TP rank 只持有序列的 1/tp_size 片段
# 进入 Attention/MLP 前,需要 AllGather 还原完整序列
def _gather_along_first_dim(input_, group):
world_size = torch.distributed.get_world_size(group=group)
output = torch.empty(
[input_.shape[0] * world_size] + list(input_.shape[1:]),
dtype=input_.dtype, device=input_.device,
)
torch.distributed.all_gather_into_tensor(
output, input_.contiguous(), group=group
)
return output
# CP 下,每个 rank 只有部分序列的 KV
# 做 Attention 前,需要异步 AllGather 收集完整 KV
handle = torch.distributed.all_gather_into_tensor(
output_tensor, input_tensor, group=self.group, async_op=True # 异步!
)
self.handles.append(handle)
# 计算和通信重叠:当前层计算时,下一层的 KV 已在后台收集
- AllReduce:输入输出大小相同,做逐元素求和。适合梯度聚合
- AllGather:输出是输入的N 倍,做拼接。适合从分片还原完整数据
Autograd 互逆配对机制 源码
Megatron 通过 torch.autograd.Function 实现前向/反向的通信自动配对。前向 AllGather → 反向自动 ReduceScatter,反之亦然。这不是人为约定,而是链式法则的数学必然:AllGather 把 N 份 D/N 拼成 D,其反向操作就是把 D 的梯度归约后切回 D/N。
# Sequence Parallel: 进入 Attention/MLP 前需要完整序列
class _GatherFromSequenceParallelRegion(torch.autograd.Function):
@staticmethod
def forward(ctx, input_, group, ...):
# 前向: AllGather — 每个 TP rank 持有 S/tp_size,拼出完整 S
return _gather_along_first_dim(input_, group, ...)
@staticmethod
def backward(ctx, grad_output):
# 反向: ReduceScatter — 完整梯度归约后切回 S/tp_size
return _reduce_scatter_along_first_dim(grad_output, ctx.group, ...)
# Sequence Parallel: 离开 Attention/MLP 后需要切回分片
class _ReduceScatterToSequenceParallelRegion(torch.autograd.Function):
@staticmethod
def forward(ctx, input_, group, ...):
# 前向: ReduceScatter — 各 rank 的部分输出归约后切片
return _reduce_scatter_along_first_dim(input_, group, ...)
@staticmethod
def backward(ctx, grad_output):
# 反向: AllGather — 梯度分片拼回完整梯度
return _gather_along_first_dim(grad_output, ctx.group, ...)
tensor_parallel_output_grad 参数控制这个选择(mappings.py L336)。
ReduceScatter:归约 + 分发
| 操作前 | 操作后 | |
|---|---|---|
| rank 0[a0 a1 a2 a3] | → | rank 0[Σx0] |
| rank 1[b0 b1 b2 b3] | → | rank 1[Σx1] |
| rank 2[c0 c1 c2 c3] | → | rank 2[Σx2] |
| rank 3[d0 d1 d2 d3] | → | rank 3[Σx3] |
是 AllGather 的逆操作,也是 AllReduce 的"省内存版"
语义:每个 rank 持有大小为 D 的数据,先对所有 rank 的数据做逐元素归约(如 SUM),然后把结果均匀切分,每个 rank 只拿到属于自己的 D/N。
通信量:每 GPU 发送 \(\frac{N-1}{N} \cdot D \approx D\)。和 AllGather 相同,是 AllReduce 的一半。但如果后续还需要 AllGather 拿回完整数据,总量就和 AllReduce 一样了。
# 分布式优化器:每个 DP rank 只负责更新 1/N 的参数
# 所以反向后不需要完整梯度(AllReduce),只需要自己负责那部分的梯度
grad_reduce_handle = dist_reduce_scatter_func(
local_data_view, # 输出: 只有本 rank 负责的梯度切片
bucket.grad_data, # 输入: 完整梯度
op=reduce_op,
group=communication_group, # DP 通信组
async_op=async_op,
)
# SP 前向: AllGather(分片 → 完整序列)
# SP 反向: ReduceScatter(完整梯度 → 分片梯度)— 刚好是 AllGather 的逆操作
def _reduce_scatter_along_first_dim(input_, group):
world_size = torch.distributed.get_world_size(group=group)
output = torch.empty(
[input_.shape[0] // world_size] + list(input_.shape[1:]),
dtype=input_.dtype, device=input_.device,
)
torch.distributed.reduce_scatter_tensor(
output, input_.contiguous(), group=group
)
return output
- 前向用 AllGather(分片 → 完整)→ 反向就用 ReduceScatter(完整 → 分片)
- 前向用 ReduceScatter → 反向就用 AllGather
ColumnParallelLinear 反向中的层内通信-计算重叠 源码
除了 DP 跨层的重叠(layer k 的 RS 和 layer k-1 的计算重叠,详见下方"分布式优化器的通信策略"一节),在同一层内部,TP 的 ReduceScatter 也和 weight gradient 的计算重叠。这是 layers.py 中 LinearWithGradAccumulationAndAsyncCommunication 的核心技巧。
def backward(ctx, grad_output):
input, weight = ctx.saved_tensors
tp_group = ctx.tp_group
# ① 计算 input 梯度(完整大小)
grad_input = grad_output.matmul(weight) # [S, H]
if ctx.sequence_parallel:
# ② 异步 ReduceScatter:把 [S, H] 归约+切分为 [S/tp, H]
sub_grad_input = torch.empty(dim_size, ...)
handle = dist_reduce_scatter_func(
sub_grad_input, grad_input,
group=tp_group, async_op=True # ← 异步!不阻塞 GPU
)
# 依赖 CUDA_DEVICE_MAX_CONNECTIONS=1 确保 RS 先于 wgrad 入队
# ③ GPU 继续计算 weight 梯度(计算密集,耗时长)
# RS 在网卡上传输的同时,GPU 在做这个矩阵乘法
if ctx.gradient_accumulation_fusion:
fused_weight_gradient_mlp_cuda.wgrad_gemm_accum_fp32(
total_input, grad_output, weight.main_grad # 融合 wgrad
)
else:
grad_weight = grad_output.t().matmul(total_input)
# ④ RS 在 ③ 执行期间大概率已完成,此时 wait 几乎不阻塞
if handle is not None:
handle.wait()
return sub_grad_input, grad_weight, ... # RS 后的分片梯度
CUDA_DEVICE_MAX_CONNECTIONS=1,确保 CUDA kernel 的调度顺序和代码顺序一致。这样 ReduceScatter 的 kernel 一定先于 weight gradient 的 kernel 入队到 GPU,保证通信能和计算真正并行。如果不设这个环境变量,CUDA 可能重排 kernel 顺序,导致通信和计算串行化。
分布式优化器的通信策略
分布式优化器将 AllReduce 拆成 ReduceScatter + AllGather 两个半量操作。通信总量不变,但获得了内存节省和更好的通信-计算重叠两大优势。
分布式优化器的完整生命周期(带具体数值) 示例
以 4 个 DP rank、模型参数 W = 4GB、Adam 优化器为例,展示一轮训练的完整数据流。
为什么能重叠?逐层流水线机制详解 可选
重叠的前提是:模型按层顺序执行。前向从 layer 0 → layer L,反向从 layer L → layer 0。每一层的计算只需要当前层的参数和梯度,不需要一次性拿到全部。
反向阶段:ReduceScatter 逐层重叠(overlap_grad_reduce)
反向计算从最后一层开始。当 layer k 的梯度算完后,不需要等其他层,立即对 layer k 的梯度发起异步 ReduceScatter。同时 GPU 继续计算 layer k-1 的梯度。
- 参数梯度 \(\frac{\partial \mathcal{L}}{\partial W_k} = \frac{\partial \mathcal{L}}{\partial a_k} \cdot a_{k-1}^\top\) — 用来更新 \(W_k\),是 ReduceScatter 的对象
- 误差信号 \(\frac{\partial \mathcal{L}}{\partial a_{k-1}} = W_k^\top \cdot \frac{\partial \mathcal{L}}{\partial a_k}\) — 传给 layer k-1 继续反向传播
计算 layer k-1 的梯度只需要误差信号,不需要参数梯度的聚合结果。误差信号是 GPU 本地计算的结果,不涉及任何跨 GPU 通信。参数梯度算出来后直接扔给网卡做 ReduceScatter,不影响后续任何层的计算。
| 层面 | 通信组 | 通信对象 | 目的 | 是否阻塞下一层? |
|---|---|---|---|---|
| TP 组内 RS | TP group(同节点 NVLink) | input 梯度 \(\frac{\partial \mathcal{L}}{\partial a_{k-1}}\) | SP 下把完整梯度切回序列分片 | 是,但 NVLink 速度极快,且和 wgrad 计算重叠 |
| DP 组内 RS | DP group(跨节点网络) | 参数梯度 \(\frac{\partial \mathcal{L}}{\partial W_k}\) | 分布式优化器聚合梯度切片 | 不阻塞,完全异步 |
# 每个参数注册反向 post-hook
# 当该参数的梯度计算完成后,hook 被自动调用
def _make_backward_post_hook(param):
def hook(*unused):
# 梯度累加到 param.main_grad
param.main_grad.add_(param.grad.data)
# 标记该参数的梯度已就绪
bucket_group.register_grad_ready(param)
return hook
# register_grad_ready 内部:当一个 bucket 内所有参数梯度都就绪时
# 自动触发异步 ReduceScatter
def register_grad_ready(self, param):
self.params_with_grad.add(param)
if len(self.params_with_grad) == self.num_params_with_grad:
self.start_grad_sync() # 发起异步 RS,async_op=True
前向阶段:AllGather 逐层预取(overlap_param_gather)
更新后每个 rank 只有 1/N 的参数。前向计算 layer 0 之前,先 AllGather 拿到 layer 0 的完整参数。同时在后台异步 AllGather layer 1 的参数。当 layer 0 算完时,layer 1 的参数已经到位。
# 每个 module 注册前向 pre-hook
# 在该 module 的 forward() 执行之前自动调用
def _make_forward_pre_hook(bucket_group):
def hook(*unused):
# ① 等待当前层的 AllGather 完成(参数就绪)
bucket_group.finish_param_sync()
# ② 立即发起下一层的异步 AllGather(预取)
next_bg = bucket_group.next_param_gather_bucket_group
if next_bg is not None:
next_bg.start_param_sync() # async_op=True
return hook
| 反向阶段可重叠 | 前向阶段可重叠 | 总可见开销 | |
|---|---|---|---|
| AllReduce | \(2|G|\) | \(0\) | \(\max(0,\; 2|G| - T_{\text{bwd}})\) |
| RS + AG | \(|G|\)(RS) | \(|G|\)(AG) | \(\max(0,\; |G| - T_{\text{bwd}}) + \max(0,\; |G| - T_{\text{fwd}})\) |
P2P Send / Recv:点对点通信
对于每个 stage,prev = 流水线中在它前面的 stage,next = 在它后面的 stage。前向数据向 next 流动(send_next / recv_prev),反向梯度向 prev 流动(send_prev / recv_next)。
语义:两个 rank 之间的单向数据传输。不像集合操作要求组内所有 rank 参与,P2P 只涉及发送方和接收方。
通信量:每次传输恰好 D(tensor 大小)。PP 中 D = 激活值大小 = micro_batch × seq_len × hidden_size,远小于参数量,这也是 PP 通信开销低的原因。
P2P 内部实现:四参数体系与 _communicate() 源码
Megatron 的 P2P 通信围绕四个核心参数构建,它们贯穿从底层 _p2p_ops() 到上层 recv_forward() 的整条调用链:
| 参数名 | 含义 | 对应的 PP 操作 | 数据内容 |
|---|---|---|---|
tensor_send_next |
发送给下一个 stage | send_forward() |
前向的输出激活值 |
tensor_recv_prev |
从上一个 stage 接收 | recv_forward() |
前向的输入激活值 |
tensor_send_prev |
发送给上一个 stage | send_backward() |
反向的输入梯度 |
tensor_recv_next |
从下一个 stage 接收 | recv_backward() |
反向的输出梯度 |
所有高层 API 最终都调用同一个 _communicate() 方法,只是填充不同的参数:
# 高层 API → _communicate() 的参数
# ─────────────────────────────────────────────────────────────────
# recv_forward() → recv_prev=True
# 返回 tensor_recv_prev(来自上一 stage 的激活值)
# send_forward(output) → tensor_send_next=output
# 把前向输出发给下一 stage
# recv_backward() → recv_next=True
# 返回 tensor_recv_next(来自下一 stage 的梯度)
# send_backward(grad) → tensor_send_prev=grad
# 把梯度发给上一 stage
# send_forward_recv_backward(output) → tensor_send_next=output, recv_next=True
# 同时:发激活值给 next + 收梯度从 next(1F1B steady 阶段用)
# send_backward_recv_forward(grad) → tensor_send_prev=grad, recv_prev=True
# 同时:发梯度给 prev + 收激活值从 prev(1F1B steady 阶段用)
send_forward_recv_backward)把两个 P2P 打包成一次调用,既减少启动开销,也是防止死锁的关键。
def _communicate(self, *, tensor_send_next, tensor_send_prev,
recv_prev, recv_next, tensor_shape, wait_on_reqs=True):
# ① 如果序列长度可变,先通信 shape
if config.variable_seq_lengths:
recv_prev_shape, recv_next_shape = self._communicate_shapes(...)
# ② 为要接收的 tensor 分配空缓冲区
if recv_prev:
tensor_recv_prev = torch.empty(recv_prev_shape, ...)
if recv_next:
tensor_recv_next = torch.empty(recv_next_shape, ...)
# ③ 选择底层实现:三选一
if config.use_ring_exchange_p2p: p2p_func = ring_exchange
elif config.batch_p2p_comm: p2p_func = _batched_p2p_ops
else: p2p_func = _p2p_ops
# ④ 调用底层,四个参数全部透传
p2p_func(tensor_send_prev=..., tensor_recv_prev=...,
tensor_send_next=..., tensor_recv_next=...,
group=pp_group, prev_pipeline_rank=..., next_pipeline_rank=...)
return tensor_recv_prev, tensor_recv_next, reqs
P2P 死锁问题与防护机制详解 可选
要理解死锁,先看一个最简单的死锁场景:
这在 1F1B 的 Steady 阶段会发生:Stage 0 做完前向要 send_next 给 Stage 1,同时 Stage 1 做完前向也要 send_next 给 Stage 2,但如果 Stage 1 同时还要 send_prev(反向梯度给 Stage 0),就出现双向同时 send。
方案一:batch_isend_irecv(默认方案)
把所有 send 和 recv 打包成一次原子调用,NCCL 内部保证不会死锁:
def _batched_p2p_ops(*, tensor_send_prev, tensor_recv_prev,
tensor_send_next, tensor_recv_next, group, ...):
ops = []
# 把所有需要的 send/recv 操作收集到列表
if tensor_send_prev is not None:
ops.append(P2POp(isend, tensor_send_prev, prev_rank, group))
if tensor_recv_prev is not None:
ops.append(P2POp(irecv, tensor_recv_prev, prev_rank, group))
if tensor_send_next is not None:
ops.append(P2POp(isend, tensor_send_next, next_rank, group))
if tensor_recv_next is not None:
ops.append(P2POp(irecv, tensor_recv_next, next_rank, group))
# 一次性提交,NCCL 内部排序,保证不死锁
reqs = torch.distributed.batch_isend_irecv(ops)
return reqs
方案二:_p2p_ops 奇偶 rank 交替顺序
如果不用 batch(某些后端不支持),就靠操作顺序打破循环等待。核心思路:偶数 rank 先 send,奇数 rank 先 recv。这样一方主动发,另一方主动收,不会互相等。
def _p2p_ops(*, tensor_send_prev, tensor_recv_prev,
tensor_send_next, tensor_recv_next, group, ...):
if group.rank() % 2 == 0: # 偶数 rank:先 send 后 recv
isend(tensor_send_next, dst=next_rank) # ① 先发给 next
irecv(tensor_recv_prev, src=prev_rank) # ② 再收从 prev
isend(tensor_send_prev, dst=prev_rank) # ③ 发给 prev
irecv(tensor_recv_next, src=next_rank) # ④ 收从 next
else: # 奇数 rank:先 recv 后 send
irecv(tensor_recv_prev, src=prev_rank) # ① 先收从 prev
isend(tensor_send_next, dst=next_rank) # ② 再发给 next
irecv(tensor_recv_next, src=next_rank) # ③ 收从 next
isend(tensor_send_prev, dst=prev_rank) # ④ 发给 prev
特殊处理:PP=2 的双 rank 通信组
当流水线只有 2 个 stage 时,一个 rank 的 prev 和 next 是同一个 rank。如果两个方向的通信用同一个 process group,NCCL 会串行化它们。Megatron 的解决方案:让一个方向用 PP group,另一个方向用 WORLD group,使两个通信独立并行。
even_send_odd_recv_group = group
if group.size() == 2 and get_backend(group) != 'ucc':
# PP 只有 2 个 stage 时,用全局 WORLD group 做一个方向的通信
# 这样两个方向的 P2P 可以真正并行,不被同一 group 串行化
even_recv_odd_send_group = torch.distributed.group.WORLD
else:
even_recv_odd_send_group = group
# 偶数 rank send 用 even_send_odd_recv_group
# 偶数 rank recv 用 even_recv_odd_send_group(WORLD)
# → 两个通信走不同的 group,可以并行
| 层级 | 机制 | 原理 |
|---|---|---|
| 底层 | batch_isend_irecv | 所有 P2P 原子提交,NCCL 内部排序 |
| 底层 | 奇偶 rank 交替 | 偶数先 send、奇数先 recv,打破循环等待 |
| 上层 | 组合 API | send_forward_recv_backward 等把双向通信合并为一次调用 |
1F1B 调度中的 P2P 通信模式 可选
1F1B 调度中的 P2P 通信模式:三个阶段用不同的 API 组合:
# Steady 阶段:每次循环做 1 个前向 + 1 个反向
for i in range(num_microbatches_remaining):
output_tensor = forward_step(input_tensor, ...)
# 发前向输出(send_next)+ 收反向梯度(recv_next)
output_tensor_grad = p2p_communicator.send_forward_recv_backward(
output_tensor, tensor_shapes
)
# 底层: _communicate(tensor_send_next=output, recv_next=True)
input_tensor_grad = backward_step(input_tensor, output_tensor_grad, ...)
# 发反向梯度(send_prev)+ 收下个前向输入(recv_prev)
input_tensor = p2p_communicator.send_backward_recv_forward(
input_tensor_grad, tensor_shapes
)
# 底层: _communicate(tensor_send_prev=grad, recv_prev=True)
AllToAll:全对全交换
| 操作前 | 操作后 | |
|---|---|---|
| rank 0a0 a1 a2 a3 | → | rank 0a0 b0 c0 d0 |
| rank 1b0 b1 b2 b3 | → | rank 1a1 b1 c1 d1 |
| rank 2c0 c1 c2 c3 | → | rank 2a2 b2 c2 d2 |
| rank 3d0 d1 d2 d3 | → | rank 3a3 b3 c3 d3 |
语义:每个 rank 持有大小为 D 的数据,切成 N 份,第 i 份发给 rank i;同时从每个 rank 收到发给自己的那一份。相当于做了一次数据重排。
通信量:每 GPU 发送 \(\frac{N-1}{N} \cdot D \approx D\)。自己那份(\(\frac{D}{N}\))不需要网络传输,其余 \(N\!-\!1\) 份各 \(\frac{D}{N}\) 通过网络发出。输入输出大小相同,不增不减。
# Sequence Parallel 布局: [S/TP, B, H] 每个 rank 有部分序列、完整 hidden
# Head Parallel 布局: [S, B, H/TP] 每个 rank 有完整序列、部分 head
# AllToAll 在两种布局间转换
# SP → HP(进入 Attention 前)
torch.distributed.all_to_all_single(
output, input,
output_split_sizes=output_split_sizes,
input_split_sizes=input_split_sizes,
group=group,
)
# HP → SP(Attention 完成后)— 再做一次 AllToAll 就回来了
Megatron 中的完整映射
- Rank 与并行组 — 通信组是如何划分的
- pretrain_gpt.py 源码精读 — 训练入口与 PP 调度