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 1空→rank 1[A B]
rank 2空→rank 2[A B]
rank 3空→rank 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.py 中 LinearWithGradAccumulationAndAsyncCommunication 的核心技巧。

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_next) Phase 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 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 的第 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 组
相关文档