手写一段causal attention,理论上该有五个GPU算子:q和k做矩阵乘、乘缩放系数、盖因果掩码、softmax、再和v做一次矩阵乘。Hugging Face最新一篇profiler教程按这个公式写完代码,打开trace一看,跑出来的是六个kernel。多出来的那个,是一次没人在代码里写过的memory copy。
这不是bug,是PyTorch的默认行为在作祟——但顺着这条线索往下挖,牵出的是一整套关于Attention为什么必须做成融合内核的解释。
一个下划线,一个消失的kernel
问题出在masked_fill。这是个out-of-place操作:PyTorch会先复制一份张量,再在副本上盖掩码,原张量留着不动。复制这一步,就是trace里那个多余的memcpy kernel。
修法很简单:把masked_fill换成masked_fill_,加一个下划线。PyTorch里带下划线的方法都是原地操作。改完重新跑一遍,GPU lane里那个memcpy没了,CPU端的算子数量也肉眼可见地减少。
- 风险.out-of-place是PyTorch的默认设计,不是失误。autograd做反向传播时要读前向的原始张量值,原地操作会把这些值覆盖掉,backward会读到错的数。这次能安全用
masked_fill_,是因为整段代码跑在torch.no_grad下,没有反向传播需要保护。真实训练代码里不能照抄。
单看一次forward,省下的这一个kernel时间很短。但Transformer里这段逻辑每层都要跑一次,层数一多,账就不是小账了。
这条"隐藏浪费",正是SDPA存在的理由
把这次发现放大看,会更清楚问题的分量。PyTorch官方把整套attention流程封装进了F.scaled_dot_product_attention(SDPA),运行时自动在多个后端里挑最快的一个。其中最"老实"的一个后端叫math——本质上就是这篇文章手写的那套朴素实现,只是更谨慎:全程升到FP32保精度,每次调用都重新生成一遍因果掩码,还用_safe_softmax防止被掩码盖满的行算出NaN。
代价很直接:math后端每次forward要发起20个GPU kernel,而手写的朴素版本只有5个;CUDA平均耗时从1.955毫秒涨到7.239毫秒,Self CUDA时间总量从7.194毫秒涨到27.279毫秒,慢了3.7倍。
math后端慢,不是因为它笨,是因为它的任务本来就不是快,而是永远给出正确答案,是所有后端的参照基准。真正要用来跑生产的是efficient和flash两个后端——它们把20个kernel压缩成一个融合kernel(fmha_cutlassF或pytorch_flash),全程留在bf16里,不落地那份[seq, seq]的中间分数矩阵,直接把Tensor Core用满。
这套思路不是这篇教程发明的。PyTorch 2.2版本正式把FlashAttention-2集成进SDPA作为可选后端,官方给出的数据是相比此前版本约有2倍提速,能跑到A100理论峰值FLOPs的约50%到73%。
把这两组数字放一起看,结论就明显了:手写代码里那个多余的memcpy只是最小的一个例子,math后端里那20个kernel才是同一类问题的放大版。融合内核不是性能锦上添花的选项,是把这类"隐藏在正确实现里的浪费"从工程师手里彻底收走。
trace越干净,不等于问题越好找。
trace会骗人:融合内核换来的是新的看不见
这套"打开trace数kernel"的方法论,在手写代码和math后端上非常好用——每一个算子都对应一行代码,一一对得上号。但换成真正融合、甚至经过编译优化的实现(比如PyTorch的FlexAttention),情况会反过来:社区里有反馈指出,这类实现的profiler trace会出现明显的空隙,变得稀疏,逐算子对应关系不再成立。
这是个容易被忽略的权衡:内核越融合,运行时越快,但trace的可读性越差。工程师排查一个融合kernel内部到底在做什么,不能再靠"数kernel数量"这套朴素办法,得换更专门的工具。这一层挑战,这篇教程本身没有展开——它到这篇为止,还停留在演示SDPA各后端的kernel签名怎么读。
另外提一句信源上的谨慎:系列第一篇《A Beginner's Guide to torch.profiler》和第二篇《From nn.Linear to a Fused MLP》的发布日期能在huggingface.co上独立核实,分别落在5月底和6月中;第三篇标注的发布日期和这套完整的作者名单,目前还缺一个可独立核实的正式页面。内容本身的技术逻辑站得住,但元信息这块,姑且信一半、留一半。
对工程师来说,该记住的两件事
第一,手写attention再自己profile,仍然是最好的入门方式——它能让你亲眼看到"多复制一份张量"这种隐藏成本长什么样。第二,写生产代码时不要重新发明轮子:直接用SDPA,让它自动调度到flash或efficient后端,这条路径已经被证明能吃到A100大半的理论峰值算力,没必要自己再造一遍math后端的弯路。
真正该盯的,是下一步——这个系列如果继续讲下去,会不会给出04_c、04_d两个脚本里efficient和flash后端更细的kernel级量化对比,以及编译类实现(FlexAttention)在trace可观测性上到底会牺牲多少诊断能力。这是手写代码解决不了的问题,也是融合内核之后剩下的真问题。
