← 返回信息流
技术博客Hugging Face Blog·9 小时前

PyTorch性能分析第三部分:注意力即剖析

原标题:Profiling in PyTorch (Part 3): Attention is all you profile

速览

PyTorch官方推出性能分析系列第三篇,聚焦注意力机制的性能剖析方法。文章以Transformer模型为例,演示如何使用PyTorch Profiler工具定位注意力计算瓶颈,并给出优化建议。这对于提升大模型训练效率具有实际指导意义。

AI 深度解读

背景

“Profiling in PyTorch” 系列旨在帮助开发者掌握读取性能分析器(profiler)跟踪轨迹(traces)和表格的能力,并以此驱动优化。在系列的第一部分中,我们分析了加法、乘法等基本数学运算的 profiling,展示了 profiler 表格如何揭示热点,以及 trace 如何展示算法随时间的执行顺序。第二部分则将加法和乘法封装进 torch.nn.Linear 层,接着堆叠多个线性层组成多层感知机(MLP)并进行 profiling,同时分析了融合内核(fused kernel)和手工调优内核。

从 Transformer 架构的角度来看,下一个合乎逻辑的 profiling 对象是另一个基础算法——注意力机制(Attention)。尽管注意力因其二次时间复杂度而“臭名昭著”,但存在许多巧妙技巧来缓解这一问题并使其变快。本文的目标不是详述每个技巧,而是观察每种技巧在 profiler 下呈现的不同形态。

所有脚本托管在相应仓库中,包含四个文件:04_a_naive_attention.py04_b_inplace_ops_attention.py04_c_sdpa_attention.py04_d_kernels_attention.py。建议在阅读时打开脚本并对照代码。实验使用 NVIDIA A100-SXM4-80GB GPU,可以在 Hugging Face 基础设施上方便地设置 GPU,并通过 Spaces 的 Dev Mode 运行脚本,或使用 Hugging Face Jobs 管道。

核心内容

朴素注意力(Naive Attention)

注意力机制涉及查询(Query, q)、键(Key, k)和值(Value, v)。它们之间的交互可写为一组简短的步骤:

  • 构建注意力得分 scoresmatmul(q, k.T)
  • 缩放得分:scores * scale
  • 应用因果掩码:scores.masked_fill(mask, "-inf")
  • 用 softmax 归一化得到注意力权重 attnsoftmax(scores)
  • 用权重重新加权值:matmul(attn, v)

因此,注意力实际上是若干基本操作的集合,其中矩阵乘法我们已经熟悉,其余操作也容易辨认。下面用 PyTorch 编写一个朴素因果注意力模块并对其 profiling:

class NaiveCausalAttention(nn.Module):
    def __init__(self, head_dim):
        super().__init__()
        self.scale = 1.0 / math.sqrt(head_dim)

    def forward(self, q, k, v, mask):
        scores = torch.matmul(q, k.transpose(-2, -1))
        scores = scores * self.scale
        scores = scores.masked_fill(mask, float("-inf"))
        attn = torch.softmax(scores, dim=-1)
        out = torch.matmul(attn, v)
        return out

在打开 trace 之前,我们做常规的预测练习。跟踪 forward 过程,预期看到:

  • 一个 matmul 内核(q · k.T
  • 一个 mul 内核(缩放)
  • 一个掩码操作
  • 一个 softmax 内核
  • 一个 matmul 内核(attn · v

运行脚本并可视化 trace(命令:uv run 04_a_naive_attention.py 然后 uvx trace-util -f traces/ -b <hf_uname>/traces)。图 1 展示了 CPU 通道(GPU 通道被折叠)。在 attn_fwd(我们标注的前向调用)内部,正好看到我们猜测的那些操作:matmulmulmasked_fillsoftmax。展开 GPU 通道后,可以看到实际启动的内核序列(图 2)。放大单个 attn_fwd 块(图 3),逐个识别内核:

  1. matmul(query 和 key)
  2. mul(缩放)
  3. 内存拷贝(Memcpy)??
  4. 因果掩码
  5. softmax(产生注意力权重)
  6. matmul(注意力权重和值)

前五个都在预期之内,但内存拷贝是意料之外的。它从何而来?线索是 PyTorch 本身有 in-place 操作。当你以普通(out-of-place)方式操作张量时,PyTorch 经常制作一份副本,对副本应用操作,然后返回副本。根据操作顺序,罪魁祸首是 masked_fill。如果将其替换为 in-place 操作呢?

带 In-place 掩码的朴素注意力

只需将 masked_fill 改为 masked_fill_(注意下划线,这是 PyTorch 中 in-place 操作的约定),并运行相同脚本。

def forward(self, q, k, v, mask):
    scores = torch.matmul(q, k.transpose(-2, -1))
    scores = torch.mul(scores, self.scale)
    scores.masked_fill_(mask, float("-inf"))   # 改为 in-place
    attn = torch.softmax(scores, dim=-1)
    out = torch.matmul(attn, v)
    return out

查看 trace(图 4 与图 5对比):in-place 版本在掩码步骤内部包裹的 CPU 操作远少于 out-of-place 版本,这是一个令人鼓舞的信号。展开 GPU 通道(图 6 和图 7),可以看到 Memcpy 内核彻底消失了。仅凭一行改动,我们在每次前向传递中砍掉了一个完整的内核。单独看可能不算多,但记住这是一个注意力操作。在基于 Transformer 的大型模型(LLM、扩散模型等)中,每层都会重复一次,而层数众多,节省量会迅速累积。

为何 out-of-place 是 PyTorch 的默认选择?为了计算梯度,autograd 必须记住前向传递中看到的张量值,因为许多反向公式会重复使用它们。In-place 操作会覆盖那些内存中的值,导致反向传递读到错误的数据。由于我们在 torch.no_grad 下运行前向,in-place 操作是安全的,没有反向传递,也不会破坏任何东西。值得一提的是,in-place 操作不仅节省时间(如同我们看到的),还节省内存(因为没有额外副本),这对 logits 这类大张量非常有利。

缩放点积注意力(Scaled Dot Product Attention, SDPA)

我们已经从基本操作构建了注意力,甚至砍掉了 Memcpy。好消息是 PyTorch 团队已经为我们完成了所有这些工作,并将整个流水线打包成一个函数:

from torch.nn import functional as F
F.scaled_dot_product_attention(q, k, v, is_causal=True)

这一行代码替代了我们手工编写的模块,is_causal=True 甚至免去了手动构建掩码。值得停下来体会这一调用隐藏了多少细节。它隐藏的不仅仅是代码行。SDPA 并非单一实现,它在底层会分派到多个后端之一,并选择支持我们输入(dtype、head 维度、掩码、硬件等)的最快后端。官方 SDPA 教程介绍了这一选择过程,后端列表定义在 torch.nn.attention.SDPBackend 枚举中:

from torch.nn.attention import SDPBackend
BACKENDS = {
    "math": SDPBackend.MATH,
    "flash": SDPBackend.FLASH_ATTENTION,
    "efficient": SDPBackend.EFFICIENT_ATTENTION,
    "cudnn": SDPBackend.CUDNN_ATTENTION,
}

通常 SDPA 会自动选择,但我们可以通过 torch.nn.attention.sdpa_kernel 上下文管理器锁定特定后端。脚本中正是这样做的,从而可以分别分析每个后端的 trace,观察它们在 trace 中的不同表现。

Math 后端

运行命令:uv run 04_c_sdpa_attention.py --backend math 然后可视化。在打开 trace 前,先猜测:我们已经将手工注意力(matmul, mul, mask, softmax, matmul)替换为一行代码,期望 trace 变得更简单、更快——更少的内核、更少的 CPU 分发,甚至可能有一个融合内核。

(原文至此中断,但

查看原文 →huggingface.co