Logo

Deep Learning Architect at NVIDIA

yifany AT csail.mit.edu
Google Scholar
Github
LinkedIn
Curriculum Vitae

使用 Programmatic Dependent Launch (PDL) 降低端到端延迟 中文版

免责声明:本博客的内容反映的是我个人在业余时间学习 GPU 编程时的经验与观点。所有信息均来自公开资料,不代表 NVIDIA Corporation 或其任何关联公司的观点或立场。

本篇是英文版 Using Programmatic Dependent Launch (PDL) to Reduce End-to-End Latency 原文的中文翻译。

0. 引言

有很多方法可以降低神经网络计算(训练/推理等)的端到端延迟。其中比较值得一提的有:

在本博客中,我们重点介绍相对鲜为人知的 PDL。它是一种相对轻量的优化,能为你带来一些额外的延迟降低。 重要的是,它作用于一连串相互依赖的 kernel,而这正是神经网络训练和推理中很常见的模式。 如果这些 kernel 是相互独立的,那你应该无脑使用 multi-stream。

本博客中的所有代码都可以在这里找到。

1. 什么是 PDL?

Programmatic Dependent Launch (PDL) 是 Hopper 架构引入的一项硬件特性。 它允许同一个 stream 中相互依赖的 kernel 彼此重叠执行。 下图展示了一个 PDL 有助于降低端到端延迟的例子。

PDL Example

假设我们有两个相互依赖的全连接层 FC1 和 FC2。FC1 的输出(y)就是 FC2 的输入。我们把它们放在同一个 stream 中运行。

y = W1 * x # FC1
z = W2 * y # FC2

每个 FC 层本质上就是一个 gemm kernel。一个 gemm threadblock 的延迟大致可以分为四个部分:

prolog/mainloop(橙色部分)是用户在 kernel 中编写的代码。而 threadblock launch 开销和 grid-ending membar(绿色部分)则是执行每个 kernel 时相伴的硬件开销。

在没有 PDL 的情况下,运行 FC1+FC2 的延迟是 FC1 launch overhead + FC1 prolog + FC1 mainloop + FC1 grid-ending membar + FC2 launch overhead + FC2 prolog + FC2 mainloop + FC2 grid-ending membar。FC2 只有在 FC1 的 grid-ending membar 完成之后才会被 launch,也就是说此时 FC1 的输出已经在 global memory 中可见,对 FC2 也可见。

但请注意,FC2 的 launch 开销和 prolog 并不依赖于 FC1 的结果。只有 FC2 的 mainloop 的执行才依赖于 FC1 的结果。因此,关键路径延迟是 FC1 launch overhead + FC1 prolog + FC1 mainloop + FC1 grid-ending membar + FC2 mainloop + FC2 grid-ending membar。一旦 FC1 的 grid-ending membar 完成(即 FC1 的输出在 global memory 中可见,对 FC2 也可见),FC2 的 mainloop 就可以开始执行。

相比 baseline 的延迟,关键路径延迟省去了 FC2 launch overheadFC2 prolog。 而 PDL 让你能够达到这个关键路径延迟——它把 FC1 的 mainloop 和 grid-ending membar 的执行,与 FC2 的 launch 时间和 prolog 重叠起来。

1.1 PDL 的 ISA 与硬件支持

为了实现 PDL,暴露了两条 ptx 指令

让我们沿着时间线走一遍,看看这两条指令是如何实现 PDL 的。FC1 正常执行,直到它在 mainloop 中间碰到 griddepcontrol.launch_dependents 指令。然后 griddepcontrol.launch_dependents 通知硬件去 launch FC2。FC2 的 prolog 开始执行,与 FC1 的 mainloop 和 grid-ending membar 重叠。

由于 FC2 的 mainloop 依赖 FC1 的输出,因此需要一种同步机制来通知 FC2:FC1 的输出已经就绪 / 在 global memory 中可见。 由于 FC2 已经被 launch 了,它可能会从 global memory 中读到 FC1 过期的(stale)输出。 这就是 griddepcontrol.wait 发挥作用的地方。 它确保 FC1 和 FC2 之间正确地同步。 在 FC2 的 prolog 结束后,它会在 FC2 mainloop 的开头碰到 griddepcontrol.wait 指令。硬件会在这条指令上阻塞,直到 FC1 的 grid-ending membar 完成,也就是 FC1 的输出在 global memory 中可见。 此时 FC2 的 mainloop 读取它就是安全的了。 然后 FC2 的 mainloop 一直执行到 kernel 结束。

最后还有一点需要注意:FC1 中的每个 threadblock 都会发射一条 griddepcontrol.launch_dependents 指令。 只有当 FC1 中最后一个 threadblock 的 griddepcontrol.launch_dependents 被发射之后,硬件才会去 launch FC2。

1.2 如果我在 FC1 中把下一个 kernel 的 griddepcontrol.launch_dependents 放得过晚/过早会怎样?

本质上,通过在 kernel 中手动插入 griddepcontrol.launch_dependentsgriddepcontrol.wait,用户控制了两个 kernel 之间的重叠比例。 同时,用户也要负责通过正确放置 griddepcontrol.wait 来处理两个 kernel 之间的数据同步。

下图分别展示了用户在 FC1 中把 griddepcontrol.launch_dependents 放得过晚和过早的两种情况。

preexit

如果你把 griddepcontrol.launch_dependents 放得过晚(最极端的情况是放在 FC1 的末尾),那么和 FC2 的 prolog 之间就没有太多重叠发生,PDL 的收益就会减少。

如果你把 griddepcontrol.launch_dependents 放得过早(最极端的情况是放在 FC1 的开头),那么 FC2 的 prolog 会很早就完成。而它的 mainloop 会被 griddepcontrol.wait 阻塞,因为 FC1 还没有执行完、还没产生出输出。另一个隐患是 FC2 的 prolog 会干扰 FC1 mainloop 的执行,可能会拖慢它。

2. 如何使用 PDL?

许多重要的编程语言和框架已经支持 PDL:

这里我来演示如何在纯 CUDA C++ kernel 中启用 PDL。要让一个 kernel 启用 PDL,你只需要改三个地方:

  1. 添加 griddepcontrol.wait ptx 指令,以便与前一个 kernel 同步。
  2. 添加 griddepcontrol.launch_dependents ptx 指令,以便 launch 下一个 kernel。
  3. 在 kernel launch 配置中设置 PDL,使其以 PDL 方式被 launch。设置这个额外的 launch 配置需要用到新的 extensible launch API(cudaLaunchKernelEx)。

下面是一个启用了 PDL 的 kernel 的代码片段。

__global__ void pdl_kernel(...) {
    prolog(...); // 不依赖前一个 kernel 的输出
    asm volatile("griddepcontrol.wait;"); // 阻塞,直到前一个 kernel 的输出就绪
    mainloop1(...); // 依赖前一个 kernel 的输出
    asm volatile("griddepcontrol.launch_dependents;"); // 在这里 launch 下一个 kernel
    mainloop2(...); // 剩余的计算,会与下一个 kernel 的 prolog 重叠
}

int main() {
    // 在 kernel launch 属性中启用 pdl
    cudaLaunchAttribute attrs[1];
    attrs[0].id = cudaLaunchAttributeProgrammaticStreamSerialization;
    attrs[0].val.programmaticStreamSerializationAllowed = 1;

    // 设置 kernel launch 配置
    cudaLaunchConfig_t config;
    config.gridDim = ...;
    config.blockDim = ...;
    config.dynamicSmemBytes = ...;
    config.stream = ...;
    config.attrs = attrs;
    config.numAttrs = 1;

    // launch kernel
    cudaLaunchKernelEx(&config, pdl_kernel, ...);
}

2.1 PDL 生效的条件

为了让 PDL 的 kernel 重叠真正生效,需要满足以下条件:

  1. 当前 kernel 正确放置了 griddepcontrol.wait,以确保与前一个 kernel 的同步正确。(如果当前 kernel 与前一个 kernel 没有数据依赖,那你可以直接去掉 griddepcontrol.wait。)
  2. 当前 kernel 使用 cudaLaunchKernelEx 并设置了 PDL 属性来 launch。
  3. SM 有足够的资源同时运行两个 kernel。例如,两个 kernel 合起来的 smem 用量应该小于 SM 的 smem 容量。寄存器、线程数、warp 数、tmem 等也有类似的资源约束。如果这个条件不满足,两个 kernel 就只会被串行执行。
  4. (可选但推荐)前一个 kernel 在 kernel 中间放置了 griddepcontrol.launch_dependents,以便与当前 kernel 的 prolog 重叠。即使前一个 kernel 中没有 griddepcontrol.launch_dependents,只要前三个条件满足,PDL 重叠仍然会发生。PDL 会假设 griddepcontrol.launch_dependents 被插入在前一个 kernel 的末尾,这样前一个 kernel 的 grid-ending membar 仍然能与当前 kernel 的 launch 时间和 prolog 重叠。

因此,griddepcontrol.wait 的放置位置同时影响性能和正确性。griddepcontrol.launch_dependents 的放置位置只影响性能。

2.2 性能分析器(Profiler)支持

NVIDIA Nsight Systems 支持可视化 PDL 下的 kernel 重叠。

nsys

我用 nsys 运行了这份 demo 代码,得到了上面这张图。 可以清楚地看到,启用 PDL 后,两个 kernel 发生了重叠。 而没有 PDL 时,两个 kernel 是串行执行的。

3. 与 Megakernel 的区别

熟悉 megakernel 的专家们可能会问:这听起来和 megakernel 太像了——在 megakernel 里你也可以通过编程来控制每个 sub-kernel 何时被 launch,从而让它们彼此重叠。 确实,在这一点上它们是相似的。 但在我看来,最重要的区别在于两个相互依赖的 kernel 之间是如何同步的。

这些方式都是在更低延迟和灵活性之间做权衡。硬件同步最高效,但最不灵活。软件同步最灵活,但最不高效。

4. 总结

在本博客中,我们介绍了 PDL,一种降低神经网络计算端到端延迟的技术。

💬 Comments & Reactions