PyTorch 性能剖析(第 2 部分):从 nn.Linear 到融合 MLP
<div class="SVELTE_HYDRATER contents" data-target="RepoCodeCopy" data-props="{}"><div></div></div> <div> <a target="_blank" class="btn mb-5 font-sans text-sm no-underline" href="https://github.com/huggingface/blog/blob/main/zh/torch-mlp-fusion.md">在 GitHub 上更新</a></div> <p></p><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/thumbnail.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/oA2BCRgyXSBc9O4Vi2liUonuJwEYVSh2rgYMIy6r5lY/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL3RodW1ibmFpbC5wbmc" alt="博客文章缩略图" referrerpolicy="no-referrer"></a><p></p> <p>在 <a href="https://huggingface.co/blog/torch-profiler">「PyTorch 性能剖析 (profiling)」系列的第一部分</a> 中,我们用 <code>torch.add(torch.matmul(x, w), b)</code> 学习了如何阅读 PyTorch 性能剖析器轨迹。沿途还讨论了几个相关主题:CPU 调度链、启动开销、开销受限与计算受限两种状态的区别,以及 <code>torch.compile</code> 的一些内部机制。</p> <p>到了第二篇(也就是本文),我们再向上迈一步。我们会把手写的矩阵乘加组合替换为 <code>nn.Linear</code>(设置 <code>bias=True</code>)。这是每个深度学习模型都会用到的基础构件。随后,我们会把三个 <code>nn.Linear</code>(本例中的具体设置)堆叠起来,并在中间加入激活函数,构成一个多层感知器(MLP)模块。</p> <blockquote class="note"> <p>本文脚本在这里:<a href="https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/02_linear.py"><code>02_linear.py</code></a>、<a href="https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/03_simple_mlp.py"><code>03_simple_mlp.py</code></a> 和 <a href="https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/03_kernels_mlp.py"><code>03_kernels_mlp.py</code></a>。和上一篇一样,建议在单独的标签页打开脚本,边读文章边对照代码。本文使用 <code>NVIDIA A100-SXM4-80GB</code> GPU 运行脚本。在 Hugging Face 基础设施上启动 GPU 并通过 <a href="https://huggingface.co/docs/hub/spaces-dev-mode">Spaces Dev Mode</a> 运行这些脚本很方便。你也可以用 <a href="https://huggingface.co/docs/huggingface_hub/en/guides/jobs">Hugging Face Jobs pipeline</a> 执行脚本。</p> </blockquote> <p>开始之前,先快速回顾两个后文会反复用到的概念:</p> <ol> <li>GPU <strong>内核(kernel)</strong> 是在 GPU 的大量线程上并行运行的程序。</li> <li>CPU 负责<strong>调度并启动</strong>这些内核。在 PyTorch 性能轨迹中看到的大部分开销,都是这类调度工作。</li> </ol> <h2 class="relative group flex items-baseline"> <a id="从-matmul-add-到-linear" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E4%BB%8E-matmul-add-%E5%88%B0-linear" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 从 matmul-add 到 Linear </span> </h2> <p><code>nn.Linear</code> 是对我们在 <a href="https://huggingface.co/blog/torch-profiler">第 1 部分</a> 中已经剖析过的同一组矩阵乘法与加法的模块封装。唯一的区别是,它把权重和偏置作为参数持有,并暴露出 PyTorch 用户熟悉的 <code>forward</code> 方法。</p> <pre><code class="language-py"><span class="hljs-comment"># bias=True would truly emulate the multiplication and addition</span> <span class="hljs-comment"># operations we have seen in part 1 of the series</span> linear_layer = nn.Linear(in_dim, out_dim, bias=<span class="hljs-literal">True</span>) y = linear_layer(x) </code></pre> <p>当前操作可以写成:</p> <pre><code>y = x @ w.T + b </code></pre> <p>其中 <code>x</code> 是输入,<code>w</code> 是权重,<code>b</code> 是偏置。运行 <a href="https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/02_linear.py"><code>02_linear.py</code></a>,看看性能剖析结果。</p> <pre><code class="language-bash">uv run 02_linear.py --batch 1024 --in_dim 32 --out_dim 64 uvx trace-util traces -b traces </code></pre> <blockquote class="tip"> <p><a href="https://x.com/ariG23498/status/2054811716727517374" rel="nofollow"><code>trace-util</code></a> 是一个工具,可以把轨迹同步到 <a href="https://huggingface.co/storage">Hugging Face bucket</a>,并在终端中给出 <a href="https://perfetto.dev/" rel="nofollow">Perfetto URL</a>。</p> </blockquote> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/linear-profile-trace.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/9v-wc9OB2Cf05doY8N2TKDCPqSBQky4yEQIOOCHu7c4/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL2xpbmVhci1wcm9maWxlLXRyYWNlLnBuZw" alt="一次 `nn.Linear` 前向传播的 PyTorch 性能轨迹:CPU 时间线上有三个较短的 Profile Step 和 `linear_fwd` 标注,GPU 时间线上有一个很小的内核,末尾还有一条较长的 `cudaDeviceSynchronize`" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 1:<code>nn.Linear</code> 的性能轨迹</td> </tr> </tbody> </table> </div> <p>图 1 展示了一次线性层 <code>forward</code> 调用的性能轨迹。我们用和前文轨迹类似的 <code>schedule</code> 设置来追踪线性层的 <code>forward</code> 调用,其中 <code>wait=1</code>、<code>warmup=1</code>、<code>active=3</code>。这就是为什么 CPU 和 GPU 时间线中会看到三个 Profile Step。</p> <h3 class="relative group flex items-baseline"> <a id="转置在做什么?" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E8%BD%AC%E7%BD%AE%E5%9C%A8%E5%81%9A%E4%BB%80%E4%B9%88%EF%BC%9F" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 转置在做什么? </span> </h3> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/transpose-cpu-dispatch.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/gTcTQBdMSkeAT3VPD5OnulgM_ttwzYAViy3el4iz4AA/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL3RyYW5zcG9zZS1jcHUtZGlzcGF0Y2gucG5n" alt="放大后的 CPU 调度链,显示 aten::linear 内部的 aten::t 转置算子嵌套在 aten::addmm 之前,GPU 时间线上没有对应活动" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 2:转置对应的 CPU 行</td> </tr> </tbody> </table> </div> <p>如果像图 2 那样放大性能轨迹,会发现 <code>aten::addmm</code>(乘法与加法)之前有一个 <code>aten::t</code>(转置)算子。由此可以看出,<code>nn.Linear</code> 会先转置权重参数,再与输入相乘。这就是轨迹里出现 <code>aten::t</code> 的原因。</p> <p>需要注意的是,<code>aten::t</code> 并不会真正复制或重新组织数据:它只是在 CPU 上重写张量元数据(形状和步长),用来表示转置后的矩阵。它不会在 GPU 上启动内核。可以通过两种方式验证这一点:查看轨迹中的 GPU 时间线,或查看性能剖析表里 <code>aten::t</code> 这一行及其 CUDA 耗时。</p> <h3 class="relative group flex items-baseline"> <a id="为什么没有单独的-mul-和-add-内核?" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E4%B8%BA%E4%BB%80%E4%B9%88%E6%B2%A1%E6%9C%89%E5%8D%95%E7%8B%AC%E7%9A%84-mul-%E5%92%8C-add-%E5%86%85%E6%A0%B8%EF%BC%9F" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 为什么没有单独的 <code>mul</code> 和 <code>add</code> 内核? </span> </h3> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/no-aten-add.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/igh0GVARwuNSo-RWjRxeuphE0aM2yaj7vEE1PuWBSGY/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL25vLWF0ZW4tYWRkLnBuZw" alt="线性层性能轨迹中高亮了调度链,显示 aten::linear、aten::t 和 aten::addmm,但没有单独的 aten::add 算子" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 3:线性层的性能轨迹中没有 <code>aten::add</code></td> </tr> </tbody> </table> </div> <p>如图 3 所示,线性层的调度链中没有 <code>aten::add</code>(偏置加法)。原因是偏置加法已经被 <em>折叠</em> 到矩阵乘法内核里,这种做法称为 <strong>epilogue(收尾阶段)</strong>。</p> <p><strong>Epilogue</strong> 是 GEMM(GEneral Matrix Multiply,通用矩阵乘)内核在把结果写回 HBM(High Bandwidth Memory,高带宽内存,也就是 GPU 主内存)之前,于最后阶段执行的一小段计算。添加偏置、应用激活函数、乘以常数缩放,都是典型的 epilogue。Epilogue 的意义在于避免第二次从 HBM 读取或写入数据,因为内存流量会显著增加操作成本。</p> <p><code>nn.Linear</code> 会调用 <code>torch.nn.functional.linear</code>,后者再调用 <code>aten::linear</code>。<code>aten::linear</code> 查看输入,发现传入了偏置,于是调度 <code>aten::addmm(bias, x, weight)</code>,而不是把 matmul 和 add 分开执行。<code>addmm</code> 计算的是:</p> <pre><code>out = x @ weight.T + bias </code></pre> <p>在 GPU 上运行的 cuBLAS GEMM 内核内置了偏置相加变体,<code>aten::addmm</code> 选择的正是这个内核。加法不会以单独内核的形式出现,因为它是 <strong>matmul 内核写回阶段的一部分</strong>,这正是 epilogue 的含义。</p> <p>这里可以注意一个细节。<a href="https://huggingface.co/blog/torch-profiler#did-we-fuse-the-matmul-and-add-kernels-into-one">第 1 部分 <code>--compile</code> 小节</a> 中出现的那个内核(<code>addmm</code>),正是 eager 模式下 <code>nn.Linear</code> 已经在用的内核。这里已经没有什么可供 <code>torch.compile</code> 继续融合了。下一步我们会验证这一点。</p> <h3 class="relative group flex items-baseline"> <a id="--compile-能帮助单个-linear-吗?" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#--compile-%E8%83%BD%E5%B8%AE%E5%8A%A9%E5%8D%95%E4%B8%AA-linear-%E5%90%97%EF%BC%9F" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> --compile 能帮助单个 Linear 吗? </span> </h3> <p>我们先编译 <code>forward</code> 调用,再查看性能轨迹。(性能轨迹会在<a href="https://huggingface.co/blog/zh/torch-mlp-fusion#where-did-the-transpose-go-kernel-layouts-and-pre-ops" rel="nofollow">下一节</a>中可视化。)</p> <pre><code class="language-bash">uv run 02_linear.py --batch 1024 --in_dim 32 --out_dim 64 --compile uvx trace-util traces -b traces </code></pre> <p>如果对比单个 <code>nn.Linear</code> 的 <code>forward</code> 在 eager 与 compile 模式下的轨迹,会发现:</p> <ul> <li>GPU 上是同一个 cuBLAS GEMM 内核。</li> <li>CPU 上是同一个 <code>aten::addmm</code> 算子。</li> <li>CPU 时间线上多了几行 compile 独有的事件。</li> </ul> <p>这一点值得记住。常见反应是,只要模型变慢就想上 <code>torch.compile</code>。但对于单个带偏置的 GEMM,compile 几乎没有发挥空间。这不是 bug,而是因为 compile 至少需要不止一个操作,才可能做出融合。接下来我们通过 <a href="https://huggingface.co/blog/zh/torch-mlp-fusion#stacking-two-linears-the-mlp" rel="nofollow">MLP</a> 来证明这一点。</p> <p><a id="where-did-the-transpose-go-kernel-layouts-and-pre-ops" rel="nofollow"></a></p> <h3 class="relative group flex items-baseline"> <a id="转置去哪了?内核布局与前置操作" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E8%BD%AC%E7%BD%AE%E5%8E%BB%E5%93%AA%E4%BA%86%EF%BC%9F%E5%86%85%E6%A0%B8%E5%B8%83%E5%B1%80%E4%B8%8E%E5%89%8D%E7%BD%AE%E6%93%8D%E4%BD%9C" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 转置去哪了?内核布局与前置操作 </span> </h3> <p>仔细阅读两份轨迹(eager 与 compile)的读者会注意到,eager 模式下的 CPU 调度链比编译后的调度链更长。</p> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/eager.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/WID4ByWhK-j5mX2lQD-Jwf4UUejVM5mEGf8qyivDa1E/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL2VhZ2VyLnBuZw" alt="Eager 模式下的 CPU 调度链,其中 aten::linear 下面的 aten::t 转置和 aten::addmm 分别被框出" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 4:Eager 调度链中,<code>aten::linear</code> 依次经过 <code>aten::t</code>(转置)和 <code>aten::addmm</code></td> </tr> </tbody> </table> </div> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/compile.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/rvScQs5TEybhtI7YpOoOoPIAjw3zKajlWUeZZCvX6wU/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL2NvbXBpbGUucG5n" alt="编译后的 CPU 调度链,显示一个 Torch-Compiled Region 和一次 aten::addmm 调用,没有转置算子" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 5:编译后的调度链直接调用 <code>aten::addmm</code>,没有转置</td> </tr> </tbody> </table> </div> <p>Eager 模式下,<code>aten::linear</code> 内部的 CPU 调度链是 <code>aten::t</code> 后接 <code>aten::addmm</code>(图 4)。要理解 <code>aten::t</code> 究竟做了什么,需要先绕到 <em>stride(步长)</em> 和 <em>view(视图)</em> 上。</p> <p>张量在内存中以一段连续平坦的数字序列来存储自身数据。<code>shape</code> 和 <code>stride</code> 是覆盖在这段数据上的元数据,用来告诉 PyTorch 如何遍历它:步长 <code>(s0, s1)</code> 表示「移动到下一行需要跨过 <code>s0</code> 个元素,移动到下一列需要跨过 <code>s1</code> 个元素」。只改元数据,就能得到同一份原始数据的不同 <em>view</em>,无需复制:</p> <pre><code class="language-py"><span class="hljs-meta">>>> </span>M = torch.tensor([[<span class="hljs-number">0</span>, <span class="hljs-number">1</span>], <span class="hljs-meta">... </span> [<span class="hljs-number">2</span>, <span class="hljs-number">3</span>], <span class="hljs-meta">... </span> [<span class="hljs-number">4</span>, <span class="hljs-number">5</span>]]) <span class="hljs-meta">>>> </span>M.shape, M.stride() (torch.Size([<span class="hljs-number">3</span>, <span class="hljs-number">2</span>]), (<span class="hljs-number">2</span>, <span class="hljs-number">1</span>)) <span class="hljs-comment"># two steps per row, one step per column</span> <span class="hljs-meta">>>> </span>T = M.t() <span class="hljs-comment"># transpose</span> <span class="hljs-meta">>>> </span>T.shape, T.stride() (torch.Size([<span class="hljs-number">2</span>, <span class="hljs-number">3</span>]), (<span class="hljs-number">1</span>, <span class="hljs-number">2</span>)) <span class="hljs-comment"># shape and stride swapped, data untouched</span> <span class="hljs-meta">>>> </span>T tensor([[<span class="hljs-number">0</span>, <span class="hljs-number">2</span>, <span class="hljs-number">4</span>], [<span class="hljs-number">1</span>, <span class="hljs-number">3</span>, <span class="hljs-number">5</span>]]) <span class="hljs-meta">>>> </span>T.flatten() <span class="hljs-comment"># forced to materialize, so the data is reordered</span> tensor([<span class="hljs-number">0</span>, <span class="hljs-number">2</span>, <span class="hljs-number">4</span>, <span class="hljs-number">1</span>, <span class="hljs-number">3</span>, <span class="hljs-number">5</span>]) </code></pre> <p><code>M.t()</code> 没有移动任何一个数字。它返回了一个新视图,交换了步长,因此现在按行读取时,会以转置后的顺序遍历原始缓冲区 <code>0, 1, 2, 3, 4, 5</code>。底层数据完全相同,只有元数据不同。</p> <p>这正是线性层内部 <code>aten::t</code> 的行为:它不会分配新张量,也不会复制任何数据,只是生成一个重写了步长的权重 <em>view</em>。</p> <p>从图 5 可以看出,compile 并没有移除某个 GPU 内核;它移除的是调度这个视图带来的 <em>CPU 开销</em>。Inductor 在编译时追踪了整个视图链,一次性算出最终步长,并发出一个直接的 <code>aten::addmm</code> 调用,把这些步长硬编码进去。几微秒的 CPU 工作消失了,而 GPU 执行的是完全相同的数学计算。</p> <p>也正如预期,如果输入数据不满足编译器预先计算好的步长约束,就会抛出错误。</p> <p>如果查看两份轨迹的 GPU 时间线,会看到每次 forward 都恰好只有一个内核,而且两次都是 <em>同一个</em> 内核:</p> <pre><code>cutlass_80_wmma_tensorop_bf16_s161616gemm_bf16_32x32_32x1_tn_align8 </code></pre> <p>既然没有运行转置内核,那么是谁让 GEMM 按转置后的顺序读取权重矩阵的?答案就在内核名中。看看后缀:</p> <pre><code>cutlass_80_wmma_tensorop_bf16_s161616gemm_bf16_32x32_32x1_tn_align8 ^^ </code></pre> <p>这个 <code>tn</code> 就是布局描述符。cuBLAS 和 CUTLASS 会针对不同输入布局组合预编译 <em>不同的内核二进制</em>。</p> <p><code>n</code>(non-transposed,未转置)和 <code>t</code>(transposed,转置)描述了内核在内层循环中如何遍历输入。调度器的工作是查看输入步长,判断哪种后缀组合匹配,并选择对应的预编译内核。</p> <blockquote class="tip"> <p>性能轨迹里的内核名是内核身份信息的一种摘要。如果两次运行显示同一个内核名,GPU 做的就是同一类工作。如果名称不同(例如 <code>_tn_</code> 与 <code>_nn_</code>、<code>bf16</code> 与 <code>fp16</code>,或 <code>s16816gemm</code> 与 <code>s161616gemm</code>),GPU 做的工作就不同,说明调度器走了另一条分支。学会阅读内核名,是对比性能轨迹时最有用的习惯之一。</p> </blockquote> <p><a id="stacking-two-linears-the-mlp" rel="nofollow"></a></p> <h2 class="relative group flex items-baseline"> <a id="堆叠三个-linear:mlp" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E5%A0%86%E5%8F%A0%E4%B8%89%E4%B8%AA-linear%EF%BC%9Amlp" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 堆叠三个 Linear:MLP </span> </h2> <p>本节会剖析一个多层感知机(MLP)。为了让例子更有意思,我们会剖析一个使用 GeGLU 激活变体的前馈网络(它在实践中相当常用)。这也是我们向深度学习研究史上最伟大的结尾句之一致敬的方式(图 6)。</p> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/geglu-paper.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/R2Nkp_m8Y3VDW-KvzLfF4HaU5Bav6vONu1j5yT6TGoY/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL2dlZ2x1LXBhcGVyLnBuZw" alt="GLU Variants Improve Transformer 论文的结论部分,其中高亮了结尾句:将这些架构的成功归功于神圣仁慈" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 6:<a href="https://arxiv.org/abs/2002.05202" rel="nofollow">GLU Variants Improve Transformer</a> 论文的结论部分。</td> </tr> </tbody> </table> </div> <pre><code class="language-py"><span class="hljs-keyword">class</span> <span class="hljs-title class_">SimpleGeGLUMLP</span>(nn.Module): <span class="hljs-keyword">def</span> <span class="hljs-title function_">__init__</span>(<span class="hljs-params">self, dim, hidden</span>): <span class="hljs-built_in">super</span>().__init__() self.gate_proj = nn.Linear(dim, hidden, bias=<span class="hljs-literal">False</span>) self.up_proj = nn.Linear(dim, hidden, bias=<span class="hljs-literal">False</span>) self.down_proj = nn.Linear(hidden, dim, bias=<span class="hljs-literal">False</span>) <span class="hljs-keyword">def</span> <span class="hljs-title function_">forward</span>(<span class="hljs-params">self, x</span>): g = self.gate_proj(x) u = self.up_proj(x) h = F.gelu(g, approximate=<span class="hljs-string">"tanh"</span>) m = h * u y = self.down_proj(m) <span class="hljs-keyword">return</span> y </code></pre> <p>完整脚本在这里:<a href="https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/03_simple_mlp.py"><code>03_simple_mlp.py</code></a>。执行方式如下:</p> <pre><code class="language-bash">uv run 03_simple_mlp.py --batch 64 --<span class="hljs-built_in">seq</span> 128 --dim 768 --hidden 3072 uvx trace-util traces -b traces </code></pre> <p>打开轨迹之前,先一起想想预期会看到什么。<code>forward</code> 函数做了不少计算,但大部分内容已经很熟悉。</p> <p>我们应该会看到三次 <code>aten::linear</code> 调度,对应三个 <code>nn.Linear</code> 层。还应该看到两次逐元素内核启动:一次用于 GeLU,一次用于乘法。查看轨迹之前先形成预期,是性能剖析过程中最有用的习惯:读轨迹是为了 <em>验证或推翻</em> 猜测,而不是从零开始形成猜测。</p> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/simple-mlp-eager.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/6vPVFOhbV-B8zpCtvcVaZsYWqVwO0Xhq24NTNVwjILM/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL3NpbXBsZS1tbHAtZWFnZXIucG5n" alt="GeGLU MLP 前向传播的性能轨迹,CPU 时间线上有五组被框出的事件,分别标注为 linear、linear、gelu、mul、linear" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 7:GeGLU MLP 的性能轨迹</td> </tr> </tbody> </table> </div> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/occupancy-queries.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/4WfWrc6KU8TAFYCiVlJiMutVJwDC6PzJPX3oyRakEt8/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL29jY3VwYW5jeS1xdWVyaWVzLnBuZw" alt="线性投影轨迹中高亮的 occupancy query" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 8:线性投影 CPU 时间线中高亮的 occupancy query</td> </tr> </tbody> </table> </div> <p>图 7 说明我们的预判是正确的。每次 forward(一次 <code>mlp_fwd</code>)中,GPU 恰好运行 5 个内核。图 8 高亮了线性投影层 CPU 时间线中的「occupancy query(占用率查询)」。</p> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center">算子</th> <th align="center">CPU 算子</th> <th align="center">GPU 内核</th> <th align="center">启动过程</th> </tr> </thead><tbody><tr> <td align="center"><code>gate_proj</code></td> <td align="center"><code>aten::linear</code></td> <td align="center"><code>ampere_bf16_s16816gemm_bf16_128x128_...</code></td> <td align="center">occupancy query + cudaLaunchKernel</td> </tr> <tr> <td align="center"><code>up_proj</code></td> <td align="center"><code>aten::linear</code></td> <td align="center"><code>ampere_bf16_s16816gemm_bf16_128x128_...</code></td> <td align="center">occupancy query + cudaLaunchKernel</td> </tr> <tr> <td align="center"><code>gelu</code></td> <td align="center"><code>aten::gelu</code></td> <td align="center"><code>vectorized_elementwise_kernel<4, GeluCUDAKernelImpl...></code></td> <td align="center">cudaLaunchKernel</td> </tr> <tr> <td align="center"><code>h * u</code></td> <td align="center"><code>aten::mul</code></td> <td align="center"><code>vectorized_elementwise_kernel<4, ...MulFunctor...></code></td> <td align="center">cudaLaunchKernel</td> </tr> <tr> <td align="center"><code>down_proj</code></td> <td align="center"><code>aten::linear</code></td> <td align="center"><code>ampere_bf16_s16816gemm_bf16_128x256_...</code></td> <td align="center">occupancy query + cudaLaunchKernel</td> </tr> </tbody> </table> </div> <p>三个 GEMM 在启动前都会额外调用一次 <code>cudaOccupancyMaxActiveBlocksPerMultiprocessor</code>。我们在第 1 部分有一节专门讨论过,<a href="https://huggingface.co/blog/torch-profiler#why-does-matmul-have-an-extra-cuda-runtime-call">可以在这里找到</a>。这是 cuBLAS 在确定网格尺寸。逐元素算子(GeLU 和 mul)则直接启动,没有 occupancy query。因此,「一个 linear」实际是 query + launch,而「一个逐元素算子」只是 launch。</p> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/simple-mlp-table.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/61n_xm1OHrItWfWy97USX2AvHlA3csBtCM6JZ8Af_Co/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL3NpbXBsZS1tbHAtdGFibGUucG5n" alt="GeGLU MLP 的性能剖析表列出算子名称及其 CUDA 耗时,其中 aten::transpose 和 aten::as_strided 等元数据算子的 CUDA 耗时显示为 0.000us" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 9:表格显示某些算子不会启动内核</td> </tr> </tbody> </table> </div> <p><code>aten::t</code>、<code>aten::transpose</code>、<code>aten::reshape</code>、<code>aten::view</code>、<code>aten::as_strided</code> 和 <code>aten::_unsafe_view</code> 都不会启动内核。它们在表中显示 <code>0.000us</code> 的 CUDA 耗时(图 9),因为它们只是在 CPU 上重写张量元数据(形状和步长)。读者扫一眼表格,可能会看到每个 linear 周围有大约六个算子名,但其中只有一个(<code>mm</code>)真正到达 GPU。</p> <h3 class="relative group flex items-baseline"> <a id="为什么会有两类-gemm-内核?" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E4%B8%BA%E4%BB%80%E4%B9%88%E4%BC%9A%E6%9C%89%E4%B8%A4%E7%B1%BB-gemm-%E5%86%85%E6%A0%B8%EF%BC%9F" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 为什么会有两类 GEMM 内核? </span> </h3> <p>MLP 会把 <code>[batch, seq, dim]</code> 展平成 <code>[batch * seq, dim]</code>,再进行 matmul。在命令行调用中,我们使用的 <code>batch</code> 是 64,<code>seq</code> 是 128,所以下面出现的 <code>8192</code> 来自 <code>batch * seq = 64 * 128</code>。</p> <p>从轨迹中可以看到:</p> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center">Linear</th> <th align="center"><code>aten::mm</code> 输入维度</th> <th align="center">M·K·N</th> <th align="center">cuBLAS 内核</th> <th align="center">平均 CUDA 耗时</th> </tr> </thead><tbody><tr> <td align="center"><code>gate_proj</code></td> <td align="center"><code>[8192,768] x [768,3072]</code></td> <td align="center"><code>8192·768·3072</code></td> <td align="center"><code>…128x128…stages_32x5_tn</code></td> <td align="center">0.19ms</td> </tr> <tr> <td align="center"><code>up_proj</code></td> <td align="center"><code>[8192,768] x [768,3072]</code></td> <td align="center"><code>8192·768·3072</code></td> <td align="center"><code>…128x128…stages_32x5_tn</code></td> <td align="center">0.19ms</td> </tr> <tr> <td align="center"><code>down_proj</code></td> <td align="center"><code>[8192,3072] x [3072,768]</code></td> <td align="center"><code>8192·3072·768</code></td> <td align="center"><code>…128x256…stages_64x3_tn</code></td> <td align="center">0.17ms</td> </tr> </tbody> </table> </div> <p>三个 GEMM 的 FLOP 数相同,都是 <code>2·8192·768·3072 ≈ 38.7 GFLOP</code>,但 <code>down_proj</code> 快约 <code>10%</code>。同样的计算量,不同的形状(<code>N=768</code> 而不是 <code>3072</code>),让 cuBLAS 选择了不同的 tile(<code>128×256</code>,并使用更深的 <code>stages_64x3</code> 流水线),从而在该形状下获得更好的复用。</p> <blockquote class="note"> <p>如果想深入学习 tiling,可以从<a href="https://alvinwan.com/how-to-tile-matrix-multiplication/" rel="nofollow">这份很好的资料</a>开始。</p> </blockquote> <p>这也正解释了为什么表中有两行 GEMM(图 9):<code>128x128</code> 那一行对应 gate+up,<code>128x256</code> 那一行对应 down。</p> <h3 class="relative group flex items-baseline"> <a id="torchcompile-会做什么?" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#torchcompile-%E4%BC%9A%E5%81%9A%E4%BB%80%E4%B9%88%EF%BC%9F" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> <code>torch.compile</code> 会做什么? </span> </h3> <p>在编译 <code>forward</code> 方法并查看可视化结果之前,我们再来做一次预判思考练习,想一想我们能从性能轨迹中观察到哪些现象。这是一项很有意思的实践,每当你自己做性能剖析时,都要反复采用这种思考方式。要始终基于自己的经验预判来分析,一旦发现实际情况和预想不符,就要停下来排查背后的原因。</p> <pre><code class="language-bash">uv run 03_simple_mlp.py --batch 64 --<span class="hljs-built_in">seq</span> 128 --dim 768 --hidden 3072 --compile uvx trace-util traces -b traces </code></pre> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/simple-mlp-compile-trace.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/KIqMgtwxvCKtTpJ2xr12mGFIV4C_2lcIF4xvSer06_w/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL3NpbXBsZS1tbHAtY29tcGlsZS10cmFjZS5wbmc" alt="编译后的 GeGLU MLP 性能轨迹,CPU 时间线上显示三次 aten::mm 调用和一个融合 Triton 内核,分别标注为 mm、mm、fused、mm" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 10:编译后的 GeGLU MLP 性能轨迹</td> </tr> </tbody> </table> </div> <p>在 eager 模式下,每个 <code>nn.Linear</code> 都会展开成一串调度器算子(<code>aten::linear</code> → <code>aten::t</code> → <code>aten::transpose</code> → <code>aten::matmul</code> → <code>aten::reshape</code> → <code>aten::mm</code>)。这些是 ATen 在到达真正的 GEMM 之前会经过的高层包装。<code>torch.compile</code> 会移除这条链。</p> <p>编译后的图运行时,已经没有 linear、matmul、transpose 或 reshape,这些元数据算子都被折叠进 <code>mm</code> 的调用方式中。我们能看到三次裸露的 <code>aten::mm</code> 外部调用(图 10)。可以证明二者使用了同一个通用矩阵乘法(GEMM)内核的依据是:内核名与 eager 模式逐字节一致:gate 和 up 使用 <code>...128x128...stages_32x5_tn</code>,down 使用 <code>...128x256...stages_64x3_tn</code>。</p> <h3 class="relative group flex items-baseline"> <a id="融合后的-triton-内核" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E8%9E%8D%E5%90%88%E5%90%8E%E7%9A%84-triton-%E5%86%85%E6%A0%B8" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 融合后的 Triton 内核 </span> </h3> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/fused.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/KOECWtMBEK6LzNlDYvmFsagk1LyXLm0uTfOgmFbcMBE/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL2Z1c2VkLnBuZw" alt="编译后的 MLP 轨迹,其中 CPU 时间线上的 triton_poi_fused__unsafe_view_gelu_mul_0 内核被框出,它替代了 eager 运行中的独立 gelu 与 mul 内核" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 11:融合后的 Triton 内核</td> </tr> </tbody> </table> </div> <p>这是整篇 compile 课程的重点。Eager 模式下的两个逐元素内核(GeLU 和 mul)再加上一个 reshape,被折叠成一个内核:<code>triton_poi_fused__unsafe_view_gelu_mul_0</code>(图 11)。我们来解析这个名称:</p> <ul> <li><code>triton</code>:由 Inductor 的 Triton 后端生成(不是 cuBLAS,也不是 ATen)。</li> <li><code>poi</code>:pointwise,逐元素操作(Inductor 将逐元素内核标记为 <code>poi</code>,规约标记为 <code>red</code>,持久规约标记为 <code>per</code>)。</li> <li><code>fused__unsafe_view_gelu_mul</code>:被合并的算子:<code>_unsafe_view</code>(reshape)、GeLU 和 mul。</li> <li><code>0</code>:该图中的唯一编号。</li> </ul> <p>为什么这是一次性能优化上的提升?在动态执行模式下,中间变量 <code>h = gelu(g)</code> 是一个尺寸为 <code>[8192, 3072]</code> 的完整 BF16 张量(大小约 50MB):GeLU 内核会先将该张量写入高带宽显存(HBM),随后乘法内核又需要立刻从显存中将其读取出来。而算子融合会把这份中间数据保存在寄存器中(寄存器位于芯片内部,相比高带宽显存访问延迟更低)。Triton 内核仅会读取一次张量<code>g</code>和<code>u</code>,完成<code>gelu(g) * u</code>的运算后,只将最终结果写入一次显存,省去了中间张量在全局显存中往返读写的一整套开销。</p> <h2 class="relative group flex items-baseline"> <a id="使用手工调优内核" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E4%BD%BF%E7%94%A8%E6%89%8B%E5%B7%A5%E8%B0%83%E4%BC%98%E5%86%85%E6%A0%B8" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 使用手工调优内核 </span> </h2> <p>到目前为止,我们一直让 PyTorch(eager)和编译器(<code>torch.compile</code>)来选择内核。现在换成一个由人工专家编写并手工调优的内核。我们使用 <code>LigerGEGLUMLP</code> 层,可以通过 <code>kernels</code> 库轻松从 <a href="https://huggingface.co/kernels/kernels-community/liger-kernels">Hugging Face Hub</a> 获取它。</p> <pre><code class="language-python"><span class="hljs-keyword">from</span> kernels <span class="hljs-keyword">import</span> get_kernel kernels_layers = get_kernel(<span class="hljs-string">"kernels-community/liger-kernels"</span>, version=<span class="hljs-number">1</span>).layers kernels_geglu_mlp = kernels_layers.LigerGEGLUMLP(Config()).to(device, dtype=torch.bfloat16).<span class="hljs-built_in">eval</span>() </code></pre> <p>完整脚本在这里:<a href="https://huggingface.co/datasets/ariG23498/profiling-pytorch/blob/main/03_kernels_mlp.py"><code>03_kernels_mlp.py</code></a>。</p> <pre><code class="language-bash">uv run 03_kernels_mlp.py --batch 64 --<span class="hljs-built_in">seq</span> 128 --dim 768 --hidden 3072 uvx trace-util traces -b traces </code></pre> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/kernels-profile.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/ttW0U2lBcNqrs5s8n_6qpfYkDxGNch_d7bo5S4xPpx4/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL2tlcm5lbHMtcHJvZmlsZS5wbmc" alt="LigerGEGLUMLP 前向传播的性能轨迹,CPU 时间线上显示三个 aten::linear 组和一个 LigerGELUMulFunction 组" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 12:<code>LigerGEGLUMLP</code> 层的性能轨迹</td> </tr> </tbody> </table> </div> <p>图 12 展示了使用 Hub 上 Liger kernels 的 <code>LigerGEGLUMLP</code> 层性能轨迹。</p> <h3 class="relative group flex items-baseline"> <a id="为什么使用-kernels-库" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E4%B8%BA%E4%BB%80%E4%B9%88%E4%BD%BF%E7%94%A8-kernels-%E5%BA%93" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 为什么使用 kernels 库 </span> </h3> <p>用 Triton 或 CUDA 编写内核是一回事,<em>分发</em> 它们又是另一回事。内核必须针对 GPU 架构、CUDA 版本和 PyTorch 版本的精确组合进行编译。通常出问题的就是这一步(「在我机器上能跑」、缺少 <code>nvcc</code>、Triton 版本不对)。</p> <p><a href="https://github.com/huggingface/kernels" rel="nofollow"><code>kernels</code></a> 库会把构建步骤从本机移走。<code>get_kernel("kernels-community/liger-kernels", version=1)</code> 会从 Hugging Face Hub 下载一个<strong>预构建、版本固定</strong>的内核包,并缓存在本地(这里位于 <code>~/.cache/...kernels-community--liger-kernels</code>)。它的好处包括:</p> <ul> <li>内核在 CI 中一次性为多种架构和版本组合编译完成。开发者下载正确的二进制即可,而不用自己编译。</li> <li><code>version=1</code> 固定了精确构建,因此运行脚本的所有人都会拿到同一个内核。不会出现「我更新了某个包之后它变慢了」。</li> <li>该包暴露了一个 <code>.layers</code> 属性,里面是可直接替换的 <code>nn.Module</code>(例如 <code>LigerGEGLUMLP</code>)。把自己的模块换成它们即可,模型其他部分不需要改变。</li> </ul> <h3 class="relative group flex items-baseline"> <a id="调优内核为什么更好" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E8%B0%83%E4%BC%98%E5%86%85%E6%A0%B8%E4%B8%BA%E4%BB%80%E4%B9%88%E6%9B%B4%E5%A5%BD" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 调优内核为什么更好 </span> </h3> <p>这里说的「调优」包含两个具体含义,而且两者都能在轨迹里看到。</p> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/compile-preops.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/AMsGmt7pP3OopRQkS6t16UC7vIeRCB79_6AGKSeqiRM/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL2NvbXBpbGUtcHJlb3BzLnBuZw" alt="编译后的 MLP 轨迹中,在编译图运行前,CPU 时间线上的 TorchDynamo、prologue 和 guard 前置操作被框出" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 13:编译运行在任何 GEMM 执行前都要付出前置操作开销(Dynamo、guard、prologue)</td> </tr> </tbody> </table> </div> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="center"><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/torch-mlp-fusion/no-preops.png" rel="nofollow"><img src="https://imgproxy.citrusreader.com/7cRQEOcaX-zml6YwddKDmh4xZJ9nfGn9kP5GDgBwnUg/raw:1/aHR0cHM6Ly9odWdnaW5nZmFjZS5jby9kYXRhc2V0cy9odWdnaW5nZmFjZS9kb2N1bWVudGF0aW9uLWltYWdlcy9yZXNvbHZlL21haW4vYmxvZy90b3JjaC1tbHAtZnVzaW9uL25vLXByZW9wcy5wbmc" alt="LigerGEGLUMLP 轨迹中,原本 compile 前置操作所在的位置是空的,显示手写内核没有 Dynamo 或 guard 开销" referrerpolicy="no-referrer"></a></th> </tr> </thead><tbody><tr> <td align="center">图 14:Liger 内核没有前置操作,原本可能出现这些操作的框中是空的</td> </tr> </tbody> </table> </div> <ol> <li><p><strong>融合已经内置其中。</strong> <a href="https://huggingface.co/kernels/kernels-community/liger-kernels/blob/v1/build/torch-cuda/layers.py#L307"><code>LigerGEGLUMLP</code></a> 的 forward 是 <code>down_proj(LigerGELUMulFunction.apply(gate_proj(x), up_proj(x)))</code>。<a href="https://huggingface.co/kernels/kernels-community/liger-kernels/blob/v1/build/torch-cuda/geglu.py#L130"><code>LigerGELUMulFunction</code></a> 会运行一个 Triton 内核 <a href="https://huggingface.co/kernels/kernels-community/liger-kernels/blob/v1/build/torch-cuda/geglu.py#L97"><code>_geglu_tanh_forward_kernel</code></a>,一次完成 <code>gelu(gate) * up</code>。这与我们从 <code>torch.compile</code> 中看到的效果完全一致:中间结果不会通过 HBM 往返。这里<strong>不需要编译器</strong>就能做到,如图 13 和图 14 所示(没有 Dynamo guard、没有编译延迟,也没有重新编译风险)。</p> </li> <li><p><strong>启动参数是为硬件选好的。</strong> 内核不会随机猜测 block size。Liger 的 <a href="https://huggingface.co/kernels/kernels-community/liger-kernels/blob/v1/build/torch-cuda/geglu.py#L95"><code>calculate_settings</code></a> 会根据列数选择这些参数。</p> </li> </ol> <p>这里需要诚实看待取舍,因为原始数字可能产生误导。Liger 内核运行耗时为 <strong>92.8 µs</strong>,而 compile 运行中 Inductor 的融合内核是 <strong>89.4 µs</strong>。乍看之下,手写内核略慢,但这种比较掩盖了让它有价值的成本差异。</p> <p><code>torch.compile</code> 会针对<strong>静态形状</strong>做专门化。Inductor 的 <code>89.4 µs</code> 内核之所以快,正是因为它是为 <em>这个精确的</em> <code>[8192, 3072]</code> 问题生成的。一旦改变 batch size、sequence length 或 hidden dimension,Dynamo 就会重新追踪,并再次支付编译成本,换取一个新的专门化内核。</p> <p>因此,实际要做的取舍并非 “手写低效内核” 与 “编译后高效内核” 二选一,而是<strong>在通用型高性能内核和针对单一输入形状深度定制的专用内核之间做选择</strong>。Liger 内核只需一组启动参数,无需重新编译即可适配任意张量形状运行。它牺牲掉按不同形状定制优化所能省下的几微秒极致耗时,换来对动态变化张量形状的强兼容性。</p> <h2 class="relative group flex items-baseline"> <a id="结论" class="block pr-1.5 text-lg md:absolute md:p-1.5 md:opacity-0 md:group-hover:opacity-100 md:right-full" href="https://huggingface.co/blog/zh/torch-mlp-fusion#%E7%BB%93%E8%AE%BA" rel="nofollow"> <span class="header-link"><svg class="text-gray-500 hover:text-black dark:hover:text-gray-200 w-4" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path d="M167.594 88.393a8.001 8.001 0 0 1 0 11.314l-67.882 67.882a8 8 0 1 1-11.314-11.315l67.882-67.881a8.003 8.003 0 0 1 11.314 0zm-28.287 84.86l-28.284 28.284a40 40 0 0 1-56.567-56.567l28.284-28.284a8 8 0 0 0-11.315-11.315l-28.284 28.284a56 56 0 0 0 79.196 79.197l28.285-28.285a8 8 0 1 0-11.315-11.314zM212.852 43.14a56.002 56.002 0 0 0-79.196 0l-28.284 28.284a8 8 0 1 0 11.314 11.314l28.284-28.284a40 40 0 0 1 56.568 56.567l-28.285 28.285a8 8 0 0 0 11.315 11.314l28.284-28.284a56.065 56.065 0 0 0 0-79.196z" fill="currentColor"></path></svg></span> </a> <span> 结论 </span> </h2> <p>下表汇总了每一步改变了 GPU 上的什么,又保留了什么。</p> <div class="max-w-full overflow-auto"> <table> <thead><tr> <th align="left">设置</th> <th align="left">发生了什么变化</th> <th align="left">什么保持不变</th> </tr> </thead><tbody><tr> <td align="left">Eager <code>nn.Linear</code></td> <td align="left">基线:偏置加法已经被折叠进 GEMM epilogue(<code>addmm</code>),所以它是 <em>一个</em> cuBLAS 内核,而不是 matmul 再加 add</td> <td align="left">—</td> </tr> <tr> <td align="left">编译后的 <code>nn.Linear</code></td> <td align="left">少量 CPU 调度算子(<code>aten::t</code> 视图 bookkeeping)消失</td> <td align="left">仍是同一个 cuBLAS GEMM 内核,逐字节一致。Compile 没有可融合的内容</td> </tr> <tr> <td align="left">Eager MLP</td> <td align="left">5 个 GPU 内核:3 个 GEMM + 一个 GeLU + 一个 mul。<code>[8192, 3072]</code> 中间结果完整往返 HBM</td> <td align="left">每个 GEMM 仍是独立 linear 使用的同一类无偏置 cuBLAS 内核</td> </tr> <tr> <td align="left">编译后的 MLP</td> <td align="left">GeLU + mul + reshape 折叠成<strong>一个</strong>融合 Triton 内核;中间结果留在寄存器中。需要支付 compile 前置操作开销(Dynamo、guard)</td> <td align="left">3 个 GEMM 未受影响,cuBLAS 内核名完全相同</td> </tr> <tr> <td align="left">Liger MLP</td> <td align="left">同样完成融合,但融合内置在手写 Triton 内核中,并使用硬件调优后的启动参数,且<strong>没有</strong> Dynamo、guard 或编译延迟</td> <td align="left">3 个 GEMM 仍是同一批 cuBLAS 内核</td> </tr> </tbody> </table> </div> <p>如果说有一个习惯值得坚持下去,那就是我们在每次查看轨迹前练习的那个:<strong>先猜,再看。</strong> 先说明你预估这份追踪日志会包含哪些内容,再打开日志文件;一旦发现实际内容和你的预判不符,就把这些偏差当作屏幕上最值得研究的亮点。</p> <p>这是 <strong>PyTorch 性能剖析</strong> 系列的第二站。下一篇文章,我们将继续循序渐进地深入讲解,从当前的多层感知器模块过渡到注意力模块,最终完整介绍整个模型。</p> <p>感谢 <a href="https://huggingface.co/NoeFlandre">Noe Flandre</a> 和 <a href="https://huggingface.co/pedrogengo">Pedro Gabriel Gengo Lourenço</a> 审阅本文初稿!</p>