评价此页

Context Parallel(上下文并行)简介#

作者: Xilun Wu, Chien-Chin Huang

注意

editGitHub 上查看并编辑本教程。

先决条件
  • PyTorch 2.7 或更高版本

简介#

Context Parallel 是一种在大语言模型训练中用于降低峰值激活内存占用的方法,它通过将长输入序列分片(sharding)到多个设备上来实现。它打破了 Transformer 块中存储激活值所导致的峰值内存使用对输入序列长度的限制。

Ring Attention 是一种新颖的注意力层并行实现,对于高性能 Context Parallel 至关重要。Ring Attention 通过轮换(shuffle)KV 分片并计算部分注意力得分,重复此过程直至每个设备都处理了所有 KV 分片。目前已实现了两种 Ring Attention 变体:基于 all-gather 的 pass-KV基于 all-to-all 的 pass-KV

  1. Llama3 训练使用了基于 all-gather 的 pass-KV 算法,该算法首先对 Key 和 Value 张量执行 all-gather,随后计算本地 Query 张量分块的注意力输出。我们修改后的基于 all-gather 的 pass-KV 算法可以同时执行 KV 分片的 all-gather,并使用本地 Key 和 Value 张量分块计算本地 Query 张量分块的注意力输出,最后计算剩余 KV 分片的注意力输出。这使得注意力计算与 all-gather 通信之间能够一定程度上重叠。例如,在 Llama3 训练中,我们还在序列维度上对 freq_cis 进行了分片。

  2. 基于 all-to-all 的方法使用交错的 all-to-all 通信来轮换 KV 分片,从而使 SDPA(缩放点积注意力)计算与下一次 SDPA 所需的 all-to-all 通信能够重叠。

Context Parallel API 由两部分组成:

  1. context_parallel() 允许用户创建一个 Python 上下文,在此上下文中,SDPA 函数(torch.nn.functional.scaled_dot_product_attention)将被自动替换为 Ring Attention。若要沿某个维度对张量进行分片,只需分别将张量及其对应的分片维度传递给 buffersbuffer_seq_dims 参数。我们建议用户将沿序列维度计算的张量添加到 buffers 中并沿该维度进行分片。以 Llama3 训练为例,如果在 buffers 中遗漏了 freq_cis,将导致旋转位置编码(rotary embedding)计算错误。

  2. set_rotate_method() 允许用户在基于 all-gather 的 pass-KV 方法和基于 all-to-all 的 pass-KV 方法之间进行选择。

设置#

通过 torch.distributed.tensor.experimental.context_parallel(),用户可以轻松地对张量输入进行分片并并行化 SDPA 函数的执行。为了更好地演示此 API 的用法,我们先从一个简单的 SDPA 代码片段开始,然后使用该 API 进行并行化。

import torch
import torch.nn.functional as F

from torch.nn.attention import sdpa_kernel, SDPBackend


def sdpa_example():
    assert torch.cuda.is_available()
    torch.cuda.set_device("cuda:0")
    torch.cuda.manual_seed(0)

    batch = 8
    nheads = 8
    qkv_len = 8192
    dim = 32
    backend = SDPBackend.FLASH_ATTENTION
    dtype = (
        torch.bfloat16
        if backend == SDPBackend.FLASH_ATTENTION
        or backend == SDPBackend.CUDNN_ATTENTION
        else torch.float32
    )

    qkv = [
        torch.rand(
            (batch, nheads, qkv_len, dim),
            dtype=dtype,
            requires_grad=True,
            device='cuda',
        )
        for _ in range(3)
    ]
    # specify the SDPBackend to use
    with sdpa_kernel(backend):
        out = F.scaled_dot_product_attention(*qkv, is_causal=True)


if __name__ == "__main__":
    sdpa_example()

启用 Context Parallel#

现在,让我们首先将其适配为一个分布式程序,其中每个 rank 拥有相同的张量输入。然后,我们应用 Context Parallel API 来对输入进行分片,并将计算分布到各个 rank 上。

# file: cp_sdpa_example.py
import os

import torch
import torch.distributed as dist
import torch.nn.functional as F
from torch.distributed.device_mesh import init_device_mesh
from torch.distributed.tensor.experimental import context_parallel
from torch.distributed.tensor.experimental._attention import context_parallel_unshard
from torch.nn.attention import sdpa_kernel, SDPBackend


def context_parallel_sdpa_example(world_size: int, rank: int):
    assert torch.cuda.is_available()
    assert dist.is_nccl_available()
    torch.cuda.set_device(f"cuda:{rank}")
    torch.cuda.manual_seed(0)

    dist.init_process_group(
        backend="nccl",
        init_method="env://",
        world_size=world_size,
        rank=rank,
    )
    device_mesh = init_device_mesh(
        device_type="cuda", mesh_shape=(world_size,), mesh_dim_names=("cp",)
    )

    batch = 8
    nheads = 8
    qkv_len = 64
    dim = 32
    backend = SDPBackend.FLASH_ATTENTION
    dtype = (
        torch.bfloat16
        if backend == SDPBackend.FLASH_ATTENTION
        or backend == SDPBackend.CUDNN_ATTENTION
        else torch.float32
    )

    qkv = [
        torch.rand(
            (batch, nheads, qkv_len, dim),
            dtype=dtype,
            requires_grad=True,
            device='cuda',
        )
        for _ in range(3)
    ]
    # specify the SDPBackend to use
    with sdpa_kernel(backend):
        out = F.scaled_dot_product_attention(*qkv, is_causal=True)

    # make a clean copy of QKV for output comparison
    cp_qkv = [t.detach().clone() for t in qkv]

    with sdpa_kernel(backend):
        # This `context_parallel()` performs two actions:
        # 1. Shard the tensor objects in `buffers` in-place along the dimension
        #    specified in `buffer_seq_dims`, the tensors in `buffers` and their
        #    sharding dims in `buffer_seq_dims` are organized in the same order.
        # 2. Replace the execution of `F.scaled_dot_product_attention` with a
        #    context-paralleled-enabled Ring Attention.
        with context_parallel(
            device_mesh, buffers=tuple(cp_qkv), buffer_seq_dims=(2, 2, 2)
        ):
            cp_out = F.scaled_dot_product_attention(*cp_qkv, is_causal=True)

        # The output `cp_out` is still sharded in the same way as QKV
        # the `context_parallel_unshard` API allows users to easily
        # unshard to gain the full tensor.
        (cp_out,) = context_parallel_unshard(device_mesh, [cp_out], [2])

    assert torch.allclose(
        cp_out,
        out,
        atol=(1e-08 if dtype == torch.float32 else 1e-03 * world_size),
    )


if __name__ == "__main__":
    rank = int(os.environ["RANK"])
    world_size = int(os.environ["WORLD_SIZE"])

    try:
        context_parallel_sdpa_example(world_size, rank)
    finally:
        dist.barrier()
        dist.destroy_process_group()

你可以使用命令 torchrun --standalone --nnodes=1 --nproc-per-node=4 cp_sdpa_example.py 在 4 个 GPU 上运行上述 Context Parallel SDPA。我们通过比较 Ring Attention 的输出与单 GPU 上 SDPA 的输出,来证明其数值正确性。

选择轮换(Rotation)方法#

你可以通过使用 torch.distributed.tensor.experimental._attention.set_rotate_method() 来选择 Ring Attention 中所需的分片轮换方法。

# file: cp_sdpa_example.py
from torch.distributed.tensor.experimental._attention import set_rotate_method

set_rotate_method("alltoall")  # rotate shards using all-to-all

with sdpa_kernel(backend):
    with context_parallel(
        device_mesh, buffers=tuple(cp_qkv), buffer_seq_dims=(2, 2, 2)
    ):
        cp_out = F.scaled_dot_product_attention(*cp_qkv, is_causal=True)

默认的轮换方法是基于 all-gather 的 pass-KV。

结论#

在本教程中,我们学习了如何使用 Context Parallel API 轻松地沿序列维度对 SDPA 计算进行并行化。关于设计与实现细节、性能分析以及 TorchTitan 中的端到端训练示例,请参阅我们关于 PyTorch 原生长上下文训练 的文章。