在许多相互依赖的任务的情况下优化使用 GPU 资源

Optimal use of GPU resources in case of many interdependent tasks

在我的用例中,全局 GPU 内存有很多数据块。最好,这些数据的数量可以改变,但假设这些数据块的数量和大小保持不变也是可以的。现在,有一组函数将一些数据块作为输入并修改其中的一些。其中一些函数应该只在其他函数已经完成的情况下才开始处理。换句话说,这些函数可以以图形的形式绘制,函数是节点,边是它们之间的依赖关系。不过这些任务的顺序很弱。

我现在的问题如下:在 CUDA 中(在概念层面上)实现它的好方法是什么?

我有一个可以作为起点的想法如下:启动单个内核。该单个内核创建了一个块网格,其中的块与上述功能相对应。块间​​同步确保块仅在其前任完成执行后才开始处理数据。
我查看了如何实现它,但我没有弄清楚如何进行块间同步(如果可能的话)。

我会为任何解决方案在内存中创建一个数组 500 个节点块 * 10,000 个浮点数(= 20 MB),每 10,000 个浮点数存储为一个连续的块。 (浮点数最好能被 32 整除 => 例如,出于内存对齐的原因,10,016 个浮点数)。

解决方案 1:运行时编译(顺序,但已优化)

使用Python代码根据图形生成函数的顺序,并创建(将源代码打印成字符串)依次调用函数的小程序。每个函数都应从内存中的前任块读取输入,并将输出存储在自己的输出块中。 Python 应该输出以正确顺序调用所有函数的粘合代码(作为字符串)。

使用NVRTC(https://docs.nvidia.com/cuda/nvrtc/index.html, https://github.com/NVIDIA/pynvrtc)进行运行时间编译,编译器会优化很多

进一步的优化是不将中间结果存储在内存中,而是存储在局部变量中。它们足以满足您指定的所有情况(每个线程最多 255 个寄存器)。但是当然会使程序(稍微)更复杂。变量可以自由命名。你可以有 500 个变量。编译器将优化对寄存器的分配和重用寄存器。因此,每个节点输出都有一个变量。例如。 float node352 = f_352(node45, node182, node416);

解决方案 2:在设备上受控 运行(顺序)

python 程序创建一个列表,其中包含必须调用函数的顺序。各个函数知道从哪些内存块读取和写入哪些内存块(硬编码,或者您必须在内存结构中将其提交给它们)。

在设备内核上,for 循环是 运行,其中按顺序遍历顺序列表并调用列表中的内核。

如何指定,调用哪些函数?

列表中的函数指针可以像下面的代码一样在CPU上创建:https://leimao.github.io/blog/Pass-Function-Pointers-to-Kernels-CUDA/(不确定,如果它在Python中有效)。

或者无论主机编程语言如何,单独的内核都可以创建翻译 table:device function pointers (assign_kernel)。然后来自 Python 的列表将包含此 table.

的索引

方案三:动态并行(并行)

动态并行内核本身启动其他内核(网格)。

https://developer.nvidia.com/blog/cuda-dynamic-parallelism-api-principles/

https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#cuda-dynamic-parallelism

最大深度为 24。 父网格的状态可以交换到内存中(每级最多需要 860 MB,可能不适用于您的程序)。但这可能是一个限制。

所有这些交换可能会使并行版本再次变慢。

但优点是节点真的可以 运行 并行。

解决方案 4:使用 Cuda 流和事件(并行)

每个内核只调用一个函数。同步和调度是从 Python 完成的。但是内核 运行 异步并在完成后立即调用回调。并行的每个内核 运行ning 必须 运行 在单独的流上。

优化:您可以使用 CUDA 图 API,CUDA 通过它学习内核的顺序并可以在重放时进行额外的优化(可能使用其他浮点输入数据,但相同的图)。

所有方法

您可以尝试不同的启动配置,从每个块 32 个或更好的 64 个线程到每个块 1024 个线程。

让我们假设您的大部分或全部数据块都很大;并且你有许多不同的功能。如果前者不成立,则不清楚您是否会从一开始就将它们放在 GPU 上而受益。我们还假设这些函数对您来说是黑盒,并且您无法使用简单的局部依赖函数来识别不同缓冲区中各个值之间的细粒度依赖关系。

鉴于这些假设 - 您的工作负载基本上是 GPU 工作的典型案例,CUDA(和 OpenCL)从一开始就满足了这一要求。

传统的普通方法

您定义了多个任务流(队列);您为各种功能在这些流上安排内核;并根据函数的相互依赖性(或缓冲区处理依赖性)安排事件触发和事件等待。内核启动前的事件等待确保在满足所有先决条件之前不会处理缓冲区。然后你有不同的 CPU 线程 wait/synchronize 与这些流,让你的工作继续。

现在,就 CUDA API 而言 - 这是最基本的东西。如果您已阅读 CUDA Programming Guide, or at least the basic sections of it, you know how to do this. You could avail yourself of convenience libraries, like my API wrapper library, or if your workload fits, a higher-level offering such as NVIDIA Thrust 可能更合适。

多线程同步不是那么简单,但这仍然不是火箭科学。棘手和微妙的是选择使用多少流以及在哪个流上安排什么工作。

使用 CUDA 任务图

使用 CUDA 10.x,NVIDIA 添加 API 函数以显式创建 任务图 ,将内核和内存副本作为依赖项的节点和边;当您完成图构造 API 调用时,您可以在任何流上“安排任务图”,可以说,CUDA 运行时基本上会自动处理我上面描述的内容。

有关如何执行此操作的详细说明,请阅读:

Getting Started with CUDA Graphs

在 NVIDIA 开发者博客上。或者,对于更深入的处理 - 实际上在编程指南中有一个关于它们的部分,以及一个使用它们的小示例应用程序,simpleCudaGraphs .

白盒函数

如果你真的对你的函数了解很多,那么也许你可以创建更大的 GPU 内核来执行一些依赖处理,方法是将部分中间结果保存在寄存器或块共享内存中,并继续应用于此类本地结果的后续功能。例如,如果您的第一个内核执行 c[i] = a[i] + b[i] 而您的第二个内核执行 e[i] = d[i] * e[i],您可以改为编写一个内核,它在第一个操作之后执行第二个操作,输入 a,b,d(不需要C)。不幸的是,我不能在这里含糊不清,因为你的问题有点含糊。