01

总览:六种通信原语

分布式训练中,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 倍。

通信组 = 参与通信的 GPU 子集
每种操作都在一个通信组 (process group) 内进行。通信组就是 Rank 与并行组 文档中讲的 TP 组、PP 组、DP 组等。组内所有 rank 必须同时调用同一个集合操作,否则会死锁。
02

Broadcast:一对多广播

tensor_parallel/data.py TP 数据分发
Broadcast:rank 0 把数据复制给组内所有 rank
操作前操作后
rank 0 (src)[A B]rank 0[A B]
rank 1rank 1[A B]
rank 2rank 2[A B]
rank 3rank 3[A B]
输入: 1 份数据 → 输出: N 份相同数据

语义:一个 rank 持有数据(大小 D),复制给组内所有其他 rank。

通信量:源 rank 发送 \(\frac{N-1}{N} \cdot D\)(ring),其他 rank 各接收 D。虽然只有一个 rank 产生数据,但 ring 实现让中转负载均摊到每个 rank。

tensor_parallel/data.py — TP rank 0 广播 batch 数据 L38-90
# ① 先广播 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)
为什么 TP 组要 Broadcast
同一 TP 组内的 GPU 各自持有模型参数的不同切片,但处理的是相同的输入数据。所以只需要 TP rank 0 从 dataloader 读取数据,然后 broadcast 给组内其他 rank,避免重复读取。
03

AllReduce:全局归约

tensor_parallel/mappings.py param_and_grad_buffer.py TP + DP
AllReduce (op=SUM):每个 rank 贡献自己的数据,所有 rank 得到相同的归约结果
操作前操作后
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]
每个位置做 SUM: 1+2+3+4=10, 2+3+5+10=20 → 所有 rank 得到相同结果

语义:每个 rank 持有大小为 D 的数据,对所有 rank 的数据做逐元素归约(通常是 SUM),结果每个 rank 都有一份完整拷贝

通信量:每 GPU 发送 \(\frac{2(N-1)}{N} \cdot D \approx 2D\)。是所有集合操作中最贵的,因为它等价于 ReduceScatter + AllGather,通信量是单个操作的 2 倍。

tensor_parallel/mappings.py — TP 前向的 AllReduce L31
# 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)
param_and_grad_buffer.py — DP 梯度同步(非分布式优化器) L422-424
# 同一 DP 组内的 rank 持有相同模型,处理不同数据
# 反向后各自的梯度不同,AllReduce 求平均
torch.distributed.all_reduce(
    bucket.grad_data, op=reduce_op, group=communication_group, async_op=async_op
)
AllReduce 的通信量:\(\frac{2(N-1)}{N} \cdot D\)
NCCL 用 Ring 算法实现 AllReduce,本质是把它拆成 ReduceScatter + AllGather 两个阶段。每个 GPU 的发送量恒为 \(\frac{2(N-1)}{N} \cdot D\),不随 GPU 数量增长。
Ring AllReduce 算法推导 可选
Ring AllReduce 算法:以 4 GPU 为例 准备:将每个 GPU 的数据(大小 D)切成 N=4 份,GPU 排成环 0→1→2→3→0 GPU 0: [a0 a1 a2 a3] 每份大小 = D/4 GPU 1: [b0 b1 b2 b3] GPU 2: [c0 c1 c2 c3] GPU 3: [d0 d1 d2 d3] ──────────────────────────────────────────────────────────── 阶段一: Reduce-Scatter(N-1 = 3 步) 每步:每个 GPU 沿环发送一个 chunk(D/N),接收方与本地对应 chunk 求和 第 1 步: GPU 0─→1: a3 GPU 1─→2: b0 GPU 2─→3: c1 GPU 3─→0: d2 GPU 1 得到 a3+b3 GPU 2 得到 b0+c0 ... 第 2 步: 传递上一步刚累加过的 chunk,继续求和 第 3 步: 同理 结果:每个 GPU 恰好有一个 chunk 被完全归约 GPU 0: [Σx0 a1 a2 a3 ] ← Σx0 = a0+b0+c0+d0 GPU 1: [ b0 b1 Σx2 b3 ] GPU 2: [ c0 c1 c2 Σx3] GPU 3: [ d0 Σx1 d2 d3 ] 通信量 = (N-1) × D/N (每步发 D/N,共 N-1 步) ──────────────────────────────────────────────────────────── 阶段二: AllGather(N-1 = 3 步) 每步:每个 GPU 把自己的完整 chunk 沿环转发(不再求和,只是复制) 第 1 步: GPU 0─→1: Σx0 GPU 1─→2: Σx2 ... 第 2 步: 转发刚收到的 chunk 第 3 步: 同理 结果:每个 GPU 都有所有 N 个完整 chunk → AllReduce 完成 GPU 0: [Σx0 Σx1 Σx2 Σx3] GPU 1: [Σx0 Σx1 Σx2 Σx3] GPU 2: [Σx0 Σx1 Σx2 Σx3] GPU 3: [Σx0 Σx1 Σx2 Σx3] 通信量 = (N-1) × D/N ──────────────────────────────────────────────────────────── 总通信量(每 GPU 发送量) AllReduce = ReduceScatter + AllGather = (N-1)/N × D + (N-1)/N × D = 2(N-1)/N × D N 较大时 (N-1)/N → 1,所以 ≈ 2D 对比只用 ReduceScatter: (N-1)/N × D ≈ D → 省一半通信量
Ring 算法的优势
无论 GPU 数量 N 多大,每个 GPU 的通信量始终是 \(\frac{2(N-1)}{N} \cdot D\),不会随 N 增长。这是因为每步只传 \(\frac{D}{N}\) 的数据,步数是 \(N\!-\!1\),二者相乘把 N 消掉了。相比之下,朴素算法(所有 GPU 发给一个 GPU 汇总再广播)通信量随 N 线性增长。
04

AllGather:全局收集

tensor_parallel/mappings.py SP / 分布式优化器
AllGather:每个 rank 贡献自己的分片,所有 rank 得到完整拼接
操作前操作后
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 有 1/N → 输出: 每个 rank 有完整 N/N

语义:每个 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\) 算,因为自己的那份不用传。

tensor_parallel/mappings.py — Sequence Parallel 还原完整序列 L142-150
# 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
Context Parallel — 异步收集 KV dot_product_attention_context_parallel.py L121-124
# 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 已在后台收集
AllGather vs AllReduce
  • AllReduce:输入输出大小相同,做逐元素求和。适合梯度聚合
  • AllGather:输出是输入的N 倍,做拼接。适合从分片还原完整数据
Autograd 互逆配对机制 源码

Megatron 通过 torch.autograd.Function 实现前向/反向的通信自动配对。前向 AllGather → 反向自动 ReduceScatter,反之亦然。这不是人为约定,而是链式法则的数学必然:AllGather 把 N 份 D/N 拼成 D,其反向操作就是把 D 的梯度归约后切回 D/N。

tensor_parallel/mappings.py — 前向 AllGather ↔ 反向 ReduceScatter L296-348
# 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, ...)
tensor_parallel/mappings.py — 前向 ReduceScatter ↔ 反向 AllGather L351-377
# 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, ...)
Autograd 配对总结:前向和反向始终互为逆操作 前向操作 反向自动调用 ────────────────── ────────────────── AllGather(拼接) ←→ ReduceScatter(归约+切分) ReduceScatter(归约+切分)←→ AllGather(拼接) AllReduce(归约) ←→ Identity(恒等,直接透传) Identity(恒等) ←→ AllReduce(归约) 每一对都满足:forward(backward(x)) ≈ x(在数学上互为伴随)
为什么是 ReduceScatter 而不是 Split?
前向 AllGather 的反向为什么不是简单的 Split(只切不归约)?因为 AllGather 前向时复制了数据给所有 rank,每个 rank 都在用这份数据做计算并产生梯度。反向时,所有 rank 对同一位置的梯度贡献需要先求和再切分,这正是 ReduceScatter。只有当下游计算是完全独立(不需要归约)时,才会退化为纯 Split。Megatron 代码中用 tensor_parallel_output_grad 参数控制这个选择(mappings.py L336)。
05

ReduceScatter:归约 + 分发

param_and_grad_buffer.py tensor_parallel/mappings.py 分布式优化器
ReduceScatter (op=SUM):先归约,再把结果切片分给各 rank
操作前操作后
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]
Σx0 = a0+b0+c0+d0 · 输入: 每个 rank 有完整 N/N → 输出: 每个 rank 只有归约后的 1/N
是 AllGather 的逆操作,也是 AllReduce 的"省内存版"

语义:每个 rank 持有大小为 D 的数据,先对所有 rank 的数据做逐元素归约(如 SUM),然后把结果均匀切分,每个 rank 只拿到属于自己的 D/N。

通信量:每 GPU 发送 \(\frac{N-1}{N} \cdot D \approx D\)。和 AllGather 相同,是 AllReduce 的一半。但如果后续还需要 AllGather 拿回完整数据,总量就和 AllReduce 一样了。

param_and_grad_buffer.py — 分布式优化器用 ReduceScatter 替代 AllReduce L414-420
# 分布式优化器:每个 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,
)
tensor_parallel/mappings.py — Sequence Parallel 反向梯度分发 L182-193
# 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 是一对互逆操作
  • 前向用 AllGather(分片 → 完整)→ 反向就用 ReduceScatter(完整 → 分片)
  • 前向用 ReduceScatter → 反向就用 AllGather
这对关系贯穿 Sequence Parallel 和分布式优化器的所有代码。
ColumnParallelLinear 反向中的层内通信-计算重叠 源码

除了 DP 跨层的重叠(layer k 的 RS 和 layer k-1 的计算重叠,详见下方"分布式优化器的通信策略"一节),在同一层内部,TP 的 ReduceScatter 也和 weight gradient 的计算重叠。这是 layers.pyLinearWithGradAccumulationAndAsyncCommunication 的核心技巧。

ColumnParallelLinear 反向:层内的通信-计算重叠 前向(已完成): AllGather 得到完整 input[S, H] → output = input × W.T 反向(当前): ① grad_input = grad_output × W ← input 梯度(完整 [S, H]) ② 异步 ReduceScatter(grad_input) ← 把完整梯度归约+切回 [S/tp, H] ③ grad_weight = grad_output.T × total_input ← weight 梯度(GPU 计算密集) ④ handle.wait() ← 等 ② 完成(通常 ③ 算完前就完成了) 步骤 ② 和 ③ 并行执行:网卡做 RS,GPU 做矩阵乘法,互不干扰
tensor_parallel/layers.py — ColumnParallelLinear 反向中的异步 RS L507-573
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 的作用
Megatron 要求设置 CUDA_DEVICE_MAX_CONNECTIONS=1,确保 CUDA kernel 的调度顺序和代码顺序一致。这样 ReduceScatter 的 kernel 一定先于 weight gradient 的 kernel 入队到 GPU,保证通信能和计算真正并行。如果不设这个环境变量,CUDA 可能重排 kernel 顺序,导致通信和计算串行化。
06

分布式优化器的通信策略

param_and_grad_buffer.py distributed_data_parallel.py ReduceScatter + AllGather

分布式优化器将 AllReduce 拆成 ReduceScatter + AllGather 两个半量操作。通信总量不变,但获得了内存节省更好的通信-计算重叠两大优势。

AllReduce vs ReduceScatter + AllGather:分布式优化器的真正优势 传统 DP(AllReduce): 每个 rank 有完整梯度 G ──AllReduce──→ 每个 rank 有完整 ΣG 每个 rank 用完整 ΣG 更新完整参数 通信量: 2 × |G| 内存: 每个 rank 存完整参数 + 完整优化器状态(master weights + momentum + variance) 分布式优化器(ReduceScatter + AllGather): 每个 rank 有完整梯度 G ──ReduceScatter──→ 每个 rank 只有 ΣG 的 1/N 每个 rank 只更新 1/N 的参数 更新后 ──AllGather────→ 每个 rank 拿回完整参数 通信量: |G| + |P| ≈ 2|G| ← 和 AllReduce 一样! 内存: 每个 rank 只存 1/N 的优化器状态 ← 优势一:省内存 通信量相同,为什么还要用分布式优化器?内存:Adam 优化器状态 = 2× 参数量(FP32),N 个 rank 均摊后省 (N-1)/N ② 重叠:把 AllReduce 拆成两个半量操作,分别和不同阶段的计算重叠 ← 优势二:藏通信 ReduceScatter 和反向计算重叠(overlap_grad_reduce) AllGather 和前向计算重叠(overlap_param_gather)
分布式优化器的完整生命周期(带具体数值) 示例

4 个 DP rank、模型参数 W = 4GB、Adam 优化器为例,展示一轮训练的完整数据流。

分布式优化器完整生命周期(4 DP ranks, W = 4GB) ═══ 持久存储(始终在 GPU 上)═══ GPU 0 持有: W[0:1G] + Adam(m[0:1G], v[0:1G]) ← 只存 1/4 的参数和优化器状态 GPU 1 持有: W[1G:2G] + Adam(m[1G:2G], v[1G:2G]) GPU 2 持有: W[2G:3G] + Adam(m[2G:3G], v[2G:3G]) GPU 3 持有: W[3G:4G] + Adam(m[3G:4G], v[3G:4G]) ═══ 步骤 1: AllGather 参数 ═══(overlap_param_gather 时和上一轮前向重叠) AllGather(W): 每个 GPU 临时拿到完整的 W[0:4G] 通信量: (N-1)/N × 4GB = 3GB / GPU ═══ 步骤 2: 前向计算 ═══ 每个 GPU 用完整 W[0:4G] + 各自不同的 mini-batch 做前向 算完后释放非自己的参数分片(临时 3GB 释放) ═══ 步骤 3: 反向计算 + ReduceScatter ═══(overlap_grad_reduce 重叠) 每个 GPU 算出完整梯度 ∇W[0:4G](各自不同,因为 mini-batch 不同) ReduceScatter(∇W): GPU 0 得到: Σ∇W[0:1G] = (∇W_0 + ∇W_1 + ∇W_2 + ∇W_3)[0:1G] GPU 1 得到: Σ∇W[1G:2G] GPU 2 得到: Σ∇W[2G:3G] GPU 3 得到: Σ∇W[3G:4G] 通信量: (N-1)/N × 4GB = 3GB / GPU ═══ 步骤 4: 参数更新 ═══ GPU 0: W[0:1G] -= lr × Adam(Σ∇W[0:1G], m[0:1G], v[0:1G]) GPU 1: W[1G:2G] -= lr × Adam(Σ∇W[1G:2G], m[1G:2G], v[1G:2G]) ... 每个 GPU 只更新 1/4,只需要 1/4 的 Adam 状态 → 回到步骤 1(AllGather 参数,和下一轮前向重叠) ═══ 内存对比 ═══ 传统 AllReduce 分布式优化器 参数: 4GB × 4 = 16GB 4GB(临时) + 1GB(持久) × 4 = 8GB Adam 状态(FP32): 8GB × 4 = 32GB 2GB × 4 = 8GB 梯度: 4GB × 4 = 16GB 1GB × 4 = 4GB 总计: 64GB 20GB(节省 69%)
为什么能重叠?逐层流水线机制详解 可选

重叠的前提是:模型按层顺序执行。前向从 layer 0 → layer L,反向从 layer L → layer 0。每一层的计算只需要当前层的参数和梯度,不需要一次性拿到全部。

传统 AllReduce:2|G| 的通信只能和反向重叠 时间 ──────────────────────────────────────────────────────────→ 计算: │◄─── 前向 layer 0→L ───►│◄─── 反向 layer L→0 ───►│ 等待 │◄── 更新 ──►│ 通信: │◄── AllReduce 2|G| ────────────►│ 反向没算完时通信也没做完 剩余通信变成纯等待
分布式优化器:拆成两个 |G|,分别和不同阶段重叠 时间 ──────────────────────────────────────────────────────────→ 计算: │◄─── 前向 layer 0→L ───►│◄─── 反向 layer L→0 ───►│◄── 更新 ──►│◄── 前向 ──►│ 通信: │◄ RS layer L..0 |G| ──►│ │◄ AG layer 0..L |G| ►│ 和反向重叠 和前向重叠 两个 |G| 分别藏进两个计算阶段 → 理想情况下通信完全隐藏

反向阶段:ReduceScatter 逐层重叠overlap_grad_reduce

反向计算从最后一层开始。当 layer k 的梯度算完后,不需要等其他层,立即对 layer k 的梯度发起异步 ReduceScatter。同时 GPU 继续计算 layer k-1 的梯度。

常见误解:layer k-1 的反向需要等 layer k 的梯度聚合结果吗?
不需要。反向传播中每一层计算两样东西,它们是完全独立的数据流:
  • 参数梯度 \(\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,不影响后续任何层的计算。

误差信号 vs 参数梯度:两条独立的数据流 ∂Loss/∂a_k(从上层传来的误差信号) │ ├──→ ∂Loss/∂W_k = ∂Loss/∂a_k × a_{k-1}^T 参数梯度(本层产物) │ │ │ └── 异步 ReduceScatter → 给优化器 DP 组内聚合,与后续层无关 │ └──→ ∂Loss/∂a_{k-1} = W_k^T × ∂Loss/∂a_k 误差信号(传给下一层) │ └── GPU 本地计算,不依赖任何通信结果 layer k-1 拿到这个就能继续反向传播 结论:ReduceScatter(处理参数梯度)和下一层的反向计算(使用误差信号)完全并行,互不依赖
反向阶段的逐层重叠 layer L: │反向计算│ ├───────┤RS 异步──────│ layer L 的 RS 和 layer L-1 的计算重叠 layer L-1: │反向计算│ ├───────┤RS 异步──────│ layer L-2: │反向计算│ ├───────┤RS 异步──────│ ... ... 每层的 RS 只有 |G|/L 的数据量,很容易在下一层计算时完成
反向传播中的两层 ReduceScatter
开启 TP + SP + 分布式优化器时,反向传播中实际存在两个层面的 ReduceScatter,它们发生在不同通信组,解决不同问题:
层面通信组通信对象目的是否阻塞下一层?
TP 组内 RSTP group(同节点 NVLink)input 梯度 \(\frac{\partial \mathcal{L}}{\partial a_{k-1}}\)SP 下把完整梯度切回序列分片是,但 NVLink 速度极快,且和 wgrad 计算重叠
DP 组内 RSDP group(跨节点网络)参数梯度 \(\frac{\partial \mathcal{L}}{\partial W_k}\)分布式优化器聚合梯度切片不阻塞,完全异步
TP 组内 RS 虽然技术上会阻塞,但由于 NVLink 带宽极高(900 GB/s on NVSwitch)且数据量小(单层 input 梯度),实际延迟可忽略。真正的"通信-计算重叠"优化主要针对 DP 组内 RS,因为 DP 通信走跨节点网络(带宽低 10-100 倍)。
distributed_data_parallel.py — 反向 hook:梯度算完立即触发异步 RS L475-504
# 每个参数注册反向 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 的参数已经到位。

前向阶段的逐层预取AG layer 0│ ├──等待──┤前向计算 layer 0│ │AG layer 1──────│ layer 1 的 AG 和 layer 0 的计算重叠 ├──┤前向计算 layer 1│ │AG layer 2──────│ ├──┤前向计算 layer 2│ 只有第一层需要等待 AG 完成(冷启动),后续层的 AG 都被前一层的计算隐藏
distributed_data_parallel.py — 前向 pre-hook:等当前层 + 预取下一层 L434-473
# 每个 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 不能做到同样的重叠?
AllReduce 是一个原子操作,把 ReduceScatter 和 AllGather 绑在一起。它只能发生在反向阶段(梯度算完后),无法把 AllGather 部分推迟到前向阶段。分布式优化器通过拆开 AllReduce,让两个半量操作分别寄生在不同的计算阶段上,实现更好的重叠。
反向阶段可重叠前向阶段可重叠总可见开销
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}})\)
当计算时间 \(T_{\text{bwd}}\) 和 \(T_{\text{fwd}}\) 各自 \(> |G|\) 时(大模型通常如此),通信完全隐藏
07

P2P Send / Recv:点对点通信

pipeline_parallel/p2p_communication.py PP 流水线
flowchart LR subgraph S0["PP Stage 0"] s0[" "] end subgraph S1["PP Stage 1"] s1[" "] end subgraph S2["PP Stage 2"] s2[" "] end subgraph S3["PP Stage 3"] s3[" "] end s0 -- "send_next 前向:激活值" --> s1 s1 -- "send_next" --> s2 s2 -- "send_next" --> s3 s3 -. "send_prev 反向:梯度" .-> s2 s2 -. "send_prev" .-> s1 s1 -. "send_prev" .-> s0

对于每个 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() 反向的输出梯度
以 Stage 1 的视角看四个参数 Stage 0 (prev) Stage 1 (当前) Stage 2 (next) ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ │ │ │ 前向: │ output │──send──→ │ recv_prev │ │ │ │ │ │ │ │ │ │ │ │ 计算... │ │ │ │ │ │ │ │ │ │ │ │ send_next │──send──→ │ input │ │ │ │ │ │ │ 反向: │ │ │ recv_next │←──send── │ grad │ │ │ │ │ │ │ │ │ │ 计算... │ │ │ │ │ │ │ │ │ │ input │←──send── │ send_prev │ │ │ └────────────┘ └────────────┘ └────────────┘ 规律:前向数据向 next 流动(send_next / recv_prev) 反向梯度向 prev 流动(send_prev / recv_next)

所有高层 API 最终都调用同一个 _communicate() 方法,只是填充不同的参数:

p2p_communication.py — 高层 API 到底层参数的映射 L402-645
# 高层 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 阶段用)
为什么要组合操作?
1F1B steady 阶段,一个 stage 做完前向后立刻要做反向(另一个 micro-batch),同时前向的输出要发走、反向的梯度要收回来。如果分开做 send 和 recv,需要两次通信;组合操作(如 send_forward_recv_backward)把两个 P2P 打包成一次调用,既减少启动开销,也是防止死锁的关键。
p2p_communication.py — _communicate():所有 P2P 的入口 L254-400
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 通信最大的陷阱是死锁。如果 Stage A 先 send 给 B,而 B 也在 send 给 A,两者都在等对方 recv,就永远卡住。Megatron 用两层机制防死锁:batch_isend_irecv(打包原子提交)和奇偶 rank 交替顺序
P2P 死锁问题与防护机制详解 可选

要理解死锁,先看一个最简单的死锁场景

sequenceDiagram participant S0 as Stage 0 participant S1 as Stage 1 S0->>S1: isend(→ S1) S1->>S0: isend(→ S0) Note over S0: 等待 S1 recv... Note over S1: 等待 S0 recv... S0--xS1: irecv(← S1) 永远等不到 S1--xS0: irecv(← S0) 永远等不到 Note over S0,S1: 循环等待 = 死锁

这在 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 内部保证不会死锁:

p2p_communication.py — _batched_p2p_ops L16-51
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。这样一方主动发,另一方主动收,不会互相等。

p2p_communication.py — _p2p_ops:奇偶交替防死锁 L54-127
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
sequenceDiagram participant S0 as Stage 0(偶数 rank) participant S1 as Stage 1(奇数 rank) Note over S0: 偶数先 send Note over S1: 奇数先 recv S0->>S1: ① isend → Stage 1 S1->>S1: ① irecv ← Stage 0 ✓ 匹配! S1->>S0: ② isend → Stage 0 S0->>S0: ② irecv ← Stage 1 ✓ 匹配! Note over S0,S1: 偶数先发、奇数先收 → 不会死锁

特殊处理:PP=2 的双 rank 通信组

当流水线只有 2 个 stage 时,一个 rank 的 prev 和 next 是同一个 rank。如果两个方向的通信用同一个 process group,NCCL 会串行化它们。Megatron 的解决方案:让一个方向用 PP group,另一个方向用 WORLD group,使两个通信独立并行。

p2p_communication.py — PP=2 的特殊处理 L65-76
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,打破循环等待
上层组合 APIsend_forward_recv_backward 等把双向通信合并为一次调用
1F1B 调度中的 P2P 通信模式 可选

1F1B 调度中的 P2P 通信模式:三个阶段用不同的 API 组合:

1F1B 调度的三阶段 P2P 通信(以 Stage 1 为例) Phase 1: Warmup — 只有前向,逐个 micro-batch 填满流水线 recv_forward() → 从 Stage 0 收激活值(recv_prev) forward_step() → 计算 send_forward() → 发激活值给 Stage 2(send_nextPhase 2: 1F1B Steady — 一前一后交替,用组合操作 forward_step() send_forward_recv_backward() → send_next + recv_next 打包 发激活值给 next,同时收梯度从 next backward_step() send_backward_recv_forward() → send_prev + recv_prev 打包 发梯度给 prev,同时收下个 micro-batch 的激活值 Phase 3: Cooldown — 只有反向,排空流水线 backward_step() send_backward() → 发梯度给 Stage 0(send_prev
schedules.py — 1F1B steady 阶段的通信 L2328-2401
# 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)
08

AllToAll:全对全交换

tensor_parallel/mappings.py MoE / Context Parallel
AllToAll:每个 rank 把数据切成 N 份,分别发给 N 个 rank
操作前操作后
rank 0a0 a1 a2 a3rank 0a0 b0 c0 d0
rank 1b0 b1 b2 b3rank 1a1 b1 c1 d1
rank 2c0 c1 c2 c3rank 2a2 b2 c2 d2
rank 3d0 d1 d2 d3rank 3a3 b3 c3 d3
可以理解为矩阵转置:每个 rank 的第 i 份发给 rank i

语义:每个 rank 持有大小为 D 的数据,切成 N 份,第 i 份发给 rank i;同时从每个 rank 收到发给自己的那一份。相当于做了一次数据重排

通信量:每 GPU 发送 \(\frac{N-1}{N} \cdot D \approx D\)。自己那份(\(\frac{D}{N}\))不需要网络传输,其余 \(N\!-\!1\) 份各 \(\frac{D}{N}\) 通过网络发出。输入输出大小相同,不增不减。

tensor_parallel/mappings.py — Context Parallel 的 SP↔HP 转换 L444-450, L560-567
# 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 就回来了
MoE 中的 AllToAll
MoE(Mixture of Experts)中,每个 token 被路由到不同的 expert。但 expert 分布在不同 GPU 上,所以需要 AllToAll 把 token 发送到对应 expert 所在的 GPU,计算完再 AllToAll 发回来。
09

Megatron 中的完整映射

总结
每种并行策略使用的通信操作 Tensor Parallelism (TP) ├─ Column Parallel 前向: 各自算部分,不通信 ├─ Column Parallel 反向: AllReduce 梯度 → TP 组 ├─ Row Parallel 前向: AllReduce 部分输出求和 → TP 组 ├─ Row Parallel 反向: 各自算部分,不通信 └─ 数据分发: Broadcast (rank 0 → all) → TP 组 Sequence Parallelism (SP) ├─ 进入 Attention/MLP: AllGather(分片 → 完整序列) → TP 组 └─ 离开 Attention/MLP: ReduceScatter(完整 → 分片) → TP 组 Pipeline Parallelism (PP) ├─ 前向传递: Send 激活值给下一 stage → P2P └─ 反向传递: Send 梯度给上一 stage → P2P Data Parallelism (DP) ├─ 普通 DDP: AllReduce 梯度 → DP 组 └─ 分布式优化器: ├─ 反向后: ReduceScatter 梯度切片 → DP 组 └─ 更新后: AllGather 收集完整参数 → DP 组 Context Parallelism (CP) ├─ Attention 前: AllGather KV(异步) → CP 组 └─ Attention 后: ReduceScatter → CP 组 Expert Parallelism (EP / MoE) ├─ 路由后: AllToAll 发 token 给 expert → EP 组 └─ 计算后: AllToAll 取回结果 → EP 组
相关文档