Performance Optimization Tutorial, Part 3 [S72686]: CUDA Techniques to Maximize Concurrency and System Utilization

Myrto Papadopoulou (NVIDIA DevTech Compute)
Igor Terentyev (NVIDIA DevTech Compute)
Guillaume Thomas-Collignon (NVIDIA DevTech Compute)

GPU Technology Conference | March 18th, 2025

*** With help of: Akshay Subramaniam, Allard Hendriksen, Athena Elafrou, Ben Pinzone, David Clark

GTC'25 性能优化系列教程

本演示是 GTC'25 性能优化系列教程的一部分,该系列包含以下内容:
- 最大化内存带宽和隐藏延迟的 CUDA 技术 [S72683]
- 最大化计算和指令吞吐量的 CUDA 技术 [S72685]
- 最大化并发性和系统利用率的 CUDA 技术 [S72686]
- 在 Grace-Hopper/Blackwell 上最大化应用程序性能的 CUDA 技术 [S72687]

议程 (Agenda)

术语 (Nomenclature)

namespace cg = cooperative_groups;
using namespace cuda; // cuda::ptx::

CUDA Streams

GPU 上的异步执行

CUDA/GPU 任务(如内核、异步内存操作、主机回调等)与 CPU 是异步执行的。如下图所示,CPU 启动内核后可以继续执行其他代码,而不需要等待 GPU 内核完成。在默认情况下(未使用流),GPU 上的内核是按顺序执行的。

Page 6: 展示了 CPU 和 GPU 任务的时间线,GPU 上的内核 ker_1, ker_2, ker_3 顺序执行,而 CPU 在启动内核的间隙可以执行自己的代码 cpu_code_1, cpu_code_2。
Page 6: 展示了 CPU 和 GPU 任务的时间线,GPU 上的内核 ker_1, ker_2, ker_3 顺序执行,而 CPU 在启动内核的间隙可以执行自己的代码 cpu_code_1, cpu_code_2。

通过使用不同的 CUDA Streams,任务可以在 GPU 上彼此并行执行。这使得 GPU 能够同时处理来自不同流的任务,从而提高利用率。

Page 7: 展示了使用多个流(stream1, stream2)时,不同流中的内核(ker_1, ker_2, ker_3 vs ker_A, ker_B)可以在 GPU 上并行执行。
Page 7: 展示了使用多个流(stream1, stream2)时,不同流中的内核(ker_1, ker_2, ker_3 vs ker_A, ker_B)可以在 GPU 上并行执行。

CUDA Streams 简介

同步 (Synchronization):
- CPU 可以与一个流同步——等待该流中所有之前的任务完成。
- 流之间也可以相互同步——一个流中的下一个任务直到另一个流中的某个特定任务完成后才开始。


¹ Programmatic Dependent Launch - 将在本演示的后面部分介绍

默认流 (Default stream)

不同的流可以乱序或并发执行任务。

默认流 (0) 是特殊的:
- kernel<<<grid_size, block_size>>> 等价于 kernel<<<grid_size, block_size, 0, 0>>>
- 它是在每个上下文中隐式创建的。
- 默认情况下,它与使用默认标志创建的其他流中的操作重叠。
- 例如:以下内核将不会重叠执行。

kernel_A<<<grid_size, block_size, 0, stream_A>>>();
kernel_B<<<grid_size, block_size, 0, stream>>>(); // 这里的 stream 是默认流 0
kernel_C<<<grid_size, block_size, 0, stream_C>>>();

移除隐式同步:

建议:
- 避免使用默认流。
- 使用 cudaStreamNonBlocking 创建显式流。

流优先级 (Stream priorities)

流可以拥有优先级。
- 支持的范围: cudaDeviceGetStreamPriorityRange(...)
- 带优先级的流: cudaStreamCreateWithPriority(...)

优先级为调度任务提供了提示
- 例如,较高优先级的 CTA 将在已经运行的较低优先级 CTA 完成后立即运行,而剩余的较低优先级 CTA 将在较高优先级的 CTA 完成后运行。

下图展示了将 kernel1_w 启动到低优先级流,随后将 kernel1_h 启动到高优先级流。尽管 kernel1_w 先启动,但 kernel1_h 会抢先执行。

Page 10: Nsight Systems 时间线显示,一个低优先级流中的任务(kernel_w)启动后,一个高优先级流中的任务(kernel_h)随后启动并优先执行。
Page 10: Nsight Systems 时间线显示,一个低优先级流中的任务(kernel_w)启动后,一个高优先级流中的任务(kernel_h)随后启动并优先执行。

未来的 Nsight Systems 版本将可以直接在 profiler 界面中查看到流的优先级(通过鼠标悬停在流上)。

Page 11: Nsight Systems 截图,展示了当鼠标悬停在 Stream 13 上时,会显示其优先级为 0。
Page 11: Nsight Systems 截图,展示了当鼠标悬停在 Stream 13 上时,会显示其优先级为 0。

GPU 同步

重度同步 (Heavy synchronization):
cudaDeviceSynchronize() 会阻塞 CPU,直到所有流上的所有 GPU 任务都完成。

这会导致 GPU 任务之间出现空闲,因为 CPU 无法提前提交新的任务,从而产生“启动延迟间隙 (launch latency gap)”。

Page 12: 时间线显示,在调用 cudaDeviceSynchronize() 之后,GPU 上出现了一段空白期,因为 CPU 被阻塞,无法提前启动下一个内核。
Page 12: 时间线显示,在调用 cudaDeviceSynchronize() 之后,GPU 上出现了一段空白期,因为 CPU 被阻塞,无法提前启动下一个内核。

一个常见的不良实践是在每次内核启动后调用 cudaDeviceSynchronize()(通常仅为了检查错误或计时)。这会严重影响性能,因为它完全消除了 CPU 和 GPU 之间的并发性。

GPU 同步行为

某些 API 调用是完全阻塞和同步的——如同被 cudaDeviceSynchronize() 包围:
cudaFree(...)

Page 18 - cudaFree 的阻塞和同步行为
Page 18 - cudaFree 的阻塞和同步行为

其他一些 API 调用也可以是完全阻塞和同步的——如同被 cudaDeviceSynchronize() 包围:
cudaMemcpy(...),包括 cudaMemcpyHostToHostcudaMemcpyHostToDevicecudaMemcpyDeviceToHost
一些其他 API 函数,如 cudaDeviceSetCacheConfig(...) 也具有类似行为。

注:非参考标准,行为可能因硬件、页锁定/非页锁定内存、大小而异。

Page 19 - cudaMemcpy 的阻塞和同步行为
Page 19 - cudaMemcpy 的阻塞和同步行为

非阻塞和同步 - 默认流内核行为:

下图展示了 API 调用(CPU 时间线上的红色方块)是非阻塞的,但它在默认流中是同步的,即它会等待流中先前的任务完成后才开始执行(GPU 时间线上的红色圆圈)。

Page 20 - 默认流内核的非阻塞同步行为
Page 20 - 默认流内核的非阻塞同步行为

阻塞和异步:

下图显示 API 调用在 CPU 上是阻塞的(红色圆圈),但 GPU 上的内核执行是异步的,可以继续进行。

注:非参考标准,行为可能有所不同。

Page 21 - cudaMalloc 的阻塞异步行为
Page 21 - cudaMalloc 的阻塞异步行为

GPU 同步技巧

GPU 错误检查与计时

检查内核错误

下图展示了两种错误检查方式的区别:

Page 15: 代码示例对比,说明 cudaGetLastError() 和 cudaDeviceSynchronize() 在捕获不同类型内核错误时的行为差异。
Page 15: 代码示例对比,说明 cudaGetLastError() 和 cudaDeviceSynchronize() 在捕获不同类型内核错误时的行为差异。

计时:

良好实践:

批量异步内存复制

cudaMemcpyBatchAsync(void** dsts, void** srcs, size_t* sizes, size_t count, 
                     cudaMemcpyAttrributes* attrs, size_t* attrIdxs, size_t numAttrs, 
                     cudaStream_t stream)

流同步

cudaStreamSynchronize(stream) 会阻塞 CPU,直到先前提交到该流的所有任务完成。

Page 24 - cudaStreamSynchronize 示例
Page 24 - cudaStreamSynchronize 示例

事件

Page 25 - CUDA 事件示例
Page 25 - CUDA 事件示例

由于数据依赖性,同步是必需的。
最常见的情况:
流1中的任务消费由流2中的任务产生的数据(流1中的任务在流2中的任务完成后开始消费数据)。

cudaStreamWaitEvent(stream, event_from_other_stream):

Page 26 - 使用事件进行流间同步
Page 26 - 使用事件进行流间同步

示例 - 小波变换(Wavelet Transform)

该示例展示了如何处理一个需要将一维CPU数组转换为二维CPU数组的任务,其中输出的每一列都是独立计算的。

Page 27 - 小波变换输入与输出
Page 27 - 小波变换输入与输出

基础流程

处理流程的目标是,以一个一维CPU数组为输入,经过计算后,生成一个二维CPU数组,其中每一列都是独立计算的。

基础的串行处理流程如下:
1. 循环处理每一列
1. 步骤 1: 将当前列的数据从CPU复制到GPU。
2. 步骤 2: 在GPU上执行计算。
3. 步骤 3: 将计算结果从GPU复制回CPU。

Page 30 - 小波变换示例:处理单列的步骤
Page 32

目标:重叠计算与复制

为了提升性能,核心目标是让计算和数据复制操作能够重叠(并行)执行。理想情况下,当GPU在计算第 i+1 列时,数据传输硬件可以同时在复制第 i 列的结果。

Page 33
Page 33

优化策略:使用固定内存(Pinned Memory)

为了实现异步的设备到主机(D2H)数据传输,通常需要使用固定(Pinned)内存作为中转缓冲区。

改进后的流程分为两个复制步骤:

Page 34
Page 34

:
- 在X86架构上,需要通过固定缓冲区进行复制。
- 在Grace Hopper/Grace Blackwell (GH/GB) 架构上,如果使用 cudaMemcpyBatchAsync(...),则可能不需要固定缓冲区。

优化策略:使用多流(奇偶列)

为了进一步提高并行度,可以使用多个CUDA流。例如,可以创建两个独立的流,一个处理所有奇数索引的列,另一个处理所有偶数索引的列,从而形成两条并行的处理流水线。

性能分析与实现方法

使用NVIDIA Nsight Systems工具可以观察到,通过流技术,内存复制(Memcpy DtoH, memcpy_h2h)和内核计算(void runna...)操作可以在时间上重叠执行。

Page 38
Page 38

异步CPU任务(如H2H复制)通常可以通过专用的CPU线程和同步原语(如互斥锁)来实现。但另一种更高效的方法是使用CUDA Streams结合 cudaLaunchHostFunc(...),它允许将一个CPU函数调用插入到CUDA流中,由CUDA运行时在适当的时候回调执行。

三流流水线实现

一个更精细的实现是使用三个独立的流来管理流水线的不同阶段:
- stream_cpt:用于GPU计算。
- stream_d2h:用于设备到主机(固定内存)的异步复制。
- stream_h2h:用于主机端的回调函数,执行从固定内存到最终目标内存的复制。

Page 39
Page 39

代码实现框架

在循环中,为每一列提交异步任务到各自的流中。使用 [col & 1] 的方式来实现双缓冲(ping-pong buffering),交替使用两个缓冲区。

for (int col = 0; col < ncol; ++col) {

    // 在计算流上启动GPU内核
    kernel<<<...>>>(d_out[col & 1], d_in, ..., stream_cpt);

    // 在D2H流上启动异步内存复制(GPU -> Pinned Memory)
    cudaMemcpyAsync(h_pin[col & 1], d_out[col & 1], ..., stream_d2h);

    // 在H2H流上启动一个主机函数,用于CPU端内存复制
    cudaLaunchHostFunc(stream_h2h, fn_h2h, &pars_h2h);
}
Page 40
Page 40

主机回调函数

cudaLaunchHostFunc 调用的主机函数 fn_h2h 负责执行从固定内存到最终输出数组的 memcpy 操作。参数通过一个结构体 Pars_h2h 传递。

Page 41
Page 41

使用事件(Events)进行流间同步

为了确保流水线中各个阶段按正确的依赖关系执行(例如,必须在计算完成后才能开始复制结果),需要使用CUDA事件进行流间的显式同步。

Page 42
Page 42

带事件同步的完整代码:

通过 cudaEventRecord 记录事件,并通过 cudaStreamWaitEvent 来让一个流等待另一个流中的事件。

Page 44
Page 44

CUDA Streams 使用技巧总结

Page 45
Page 45

Programmatic Dependent Launch (PDL)

动机 (Motivation)

在标准的 CUDA 流执行模型中,流语义保证了内核的顺序执行。然而,内核之间的数据依赖关系通常是隐式的。如下图所示,Consumer kernel 3 依赖于 Producer 内核(primarysecondary)产生的数据。

Page 47 diagram illustrating PDL motivation
Page 47 diagram illustrating PDL motivation

Consumer kernel 的执行通常可以分为两个阶段:

  1. 前序(Preamble):这部分工作不依赖于 Producer 内核的输出。例如,共享内存初始化、指针运算、其他设置工作、从全局内存读取只读数据等。
  2. 处理阶段:处理由 Producer 内核生成的数据。

在传统的流执行中,Consumer kernel 必须等待 Producer 完全结束后才能开始,即使其前序部分可以提前执行。

Programmatic Dependent Launch (PDL) 允许 consumer kernel 的前序部分与 producer kernel 的执行机会性地重叠,从而提高硬件利用率。


使用方法 (CUDA streams)

PDL 通过在生产者和消费者内核中插入特定的设备 API 来协调它们的执行。

Page 48 diagram showing PDL usage with CUDA streams
Page 48 diagram showing PDL usage with CUDA streams

关键时间点:
- 在 primary (生产者) 内核中,指示 secondary (消费者) 内核可以被触发的时间点。
- 在 secondary (消费者) 内核中,指示内核应该阻塞并等待 primary 内核完成的时间点。

设备 API (适用于 CC >= 9.0):
- cudaTriggerProgrammaticLaunchCompletion
- 位置:在 primary (生产者) 内核中调用。

约束条件:


使用方法 - 内核启动(CPU端)

下图展示了如何在主机端(CPU)代码中启动使用 PDL 的内核。

Page 49 code snippets for PDL kernel launch from CPU
Page 49 code snippets for PDL kernel launch from CPU
__global__ void primary_kernel(uint8_t* d_ptr);
__global__ void secondary_kernel(uint8_t* d_ptr);
primary_kernel<<<grid_dim, block_dim, 0, strm>>>(d_ptr);
- `secondary_kernel` 的启动需要使用 `cudaLaunchKernelEx`。
    - 首先,配置常规的启动参数(`blockDim`, `gridDim`, `dynamicSmemBytes`, `stream`)。
    - 然后,需要设置一个特殊的属性 `cudaLaunchAttributeProgrammaticStreamSerialization`,并将其值 `programmaticStreamSerializationAllowed` 设为 1。
    - 最后,使用 `cudaLaunchKernelEx` 启动内核。

使用方法 - PDL API(设备代码)

下图展示了如何在设备端(GPU)内核代码中使用 PDL API。

Page 50 code snippets for PDL API usage in device code
Page 50 code snippets for PDL API usage in device code

primary_kernel (生产者):

__global__ void primary_kernel(uint8_t* d_ptr) {
    work_A();
    cudaTriggerProgrammaticLaunchCompletion();
    work_B();
}

secondary_kernel (消费者):

__global__ void secondary_kernel(uint8_t* d_ptr) {
    work_C();
    cudaGridDependencySynchronize();
    work_D();
}

使用技巧 (Tips)

Page 51 code snippets and tips for using PDL
Page 51 code snippets and tips for using PDL

3个内核的使用示例

在更复杂的依赖链中,一个内核可以同时作为消费者和生产者。

Page 52 code snippets for a 3-kernel PDL example
Page 52 code snippets for a 3-kernel PDL example

GPU 时间线 (来自 Nsight Systems)

下图展示了不同情况下 PDL 的 GPU 执行时间线。

Page 53 GPU timelines from Nsight Systems for PDL
Page 53 GPU timelines from Nsight Systems for PDL

代码示例可以在 https://github.com/NVIDIA/cuda-samples/tree/master/Samples/5_Domain_Specific/programmaticLaunch 找到。


进一步阅读


CUDA Graphs

一种工作提交模型

Page 56 explanation of CUDA Graphs
Page 56 explanation of CUDA Graphs

减少 CPU 启动提交开销

Page 57 comparison of CPU timelines for streams vs. graphs
Page 57 comparison of CPU timelines for streams vs. graphs

等效的流提交时间线

使用传统的流和事件来实现与上图相同的依赖关系会非常复杂。

Page 58 equivalent stream submission timeline for a graph
Page 58 equivalent stream submission timeline for a graph

CUDA Graphs 抽象了这种复杂的依赖管理。


减少 CPU 启动提交开销 (第二次启动)

下图比较了第二次启动相同工作时,基于流和基于图的 CPU 和 GPU 时间线。

Page 59 CPU and GPU timeline comparison for streams vs. graphs
Page 59 CPU and GPU timeline comparison for streams vs. graphs

注意: 时间线来自 Nsight Systems,使用默认的 --cuda-graph-trace graph 模式。节点级别的追踪可以通过 --cuda-graph-trace node 实现,但这可能会带来显著的开销。


关键步骤:定义、实例化、启动

Page 60 key steps for using CUDA Graph
Page 60 key steps for using CUDA Graph
  1. 定义图 (Define graph)

    • 将操作和依赖关系封装在一个 cudaGraph_t 图中。
    • 两种方式:(a) 流捕获 (stream capture) 或 (b) 使用图 API。
  2. 实例化图 (Instantiate graph)

    • 实例化图模板,生成一个可执行图 cudaGraphExec_t
    • cudaGraphInstantiate(&graph_exec, graph)
  3. 启动图 (Launch graph)

    • 可在 CUDA 流上启动可执行图:cudaGraphLaunch(graph_exec, stream)
    • 流仅用于依赖跟踪,不提供关于图节点在何处执行的信息。
    • 可选:在启动前可将图上传到流:cudaGraphUpload(graph_exec, stream)
  4. 后续操作

    • 可以根据需要多次重新启动同一个可执行图。
    • 内核参数是否改变?可以使用 cudaGraphExecKernelNodeSetParams 从 CPU 端更新节点。
    • 是否需要不执行某些节点?如果已知,可以在图启动前使用 cudaGraphNodeSetEnabled() 禁用节点。
    • 发生较大变化?重新实例化图。
  5. 销毁图 (Destroy graph)

    • 销毁可执行图和图模板。

CUDA 图创建(2 种方式)

1. 流捕获 (Stream Capture)

通过捕获在 CUDA 流上执行的工作来创建图。

Page 61
Page 61

代码示例

要点:
- 在流捕获期间,GPU 不执行工作;工作仅被捕获到图中。
- 重要提示:在核函数启动后(包括捕获期间),不要跳过 cudaGetLastError() 调用。如果核函数启动包含无效参数(例如,不支持的网格大小、动态共享内存等),你可能会在图中遇到静默丢失的核函数。
- 免责声明:为简洁起见,幻灯片中的代码示例省略了错误检查代码。


2. 使用图 API (Use graph APIs)

手动、显式地创建图节点并定义它们之间的依赖关系。

Page 62
Page 62

代码示例
1. 创建图和节点:

cudaGraph_t graph;
cudaGraphCreate(&graph, 0);
cudaGraphNode_t node_A, node_B, node_C, node_D, node_E;
cudaKernelNodeParams params[5] = {};
// <...> 填充核函数节点参数
  1. 添加根节点 (Node A):
cudaGraphAddKernelNode(&node_A, graph, nullptr, 0, &params[0]);
- `&node_A`: 要添加的图节点。
- `graph`: 要添加到的图。
- `nullptr`, `0`: 该节点的依赖项数量(0 表示是根节点)。
- `&params[0]`: 参数。
  1. 添加依赖节点 (Node B):
cudaGraphAddKernelNode(&node_B, graph, &node_A, 1, &params[1]);
- `&node_A`, `1`: `node_B` 依赖于 `node_A`。
Page 63
Page 63

代码示例(续):

std::vector<cudaGraphNode_t> node_deps = {node_B, node_C};
cudaGraphAddKernelNode(&node_D, graph, node_deps.data(), node_deps.size(), &params[3]);

CUDA 图创建方式的选择

Page 64
Page 64

这取决于具体情况,需要考虑一些权衡:


CUDA 图技巧与提醒

这是一个远未完整的列表。

Page 65
Page 65

启用条件执行

动机

处理过程可能依赖于在某些(GPU)工作处理之后才知道的运行时条件

Page 66
Page 66

示例:
- 如果你的数据具有某些特征,则进行额外的处理。
- 如果你已经得到了一个足够好的答案,跳过后续的处理。
- 如果你的处理时间过长,提前退出。


传统方法的瓶颈

CPU 评估条件并决定接下来要启动什么。

Page 67
Page 67

如上图所示,当控制流返回到 CPU 进行条件判断时:
1. CPU 启动初始数据处理。
2. CPU 等待其完成。
3. CPU 评估条件 A,然后启动算法 1。
4. CPU 再次等待完成。
5. CPU 评估条件 B,然后启动后续工作。

潜在问题:
- CPU 无法远超前于 GPU。
- GPU 时间线上出现间隙 (Gap),导致 GPU 空闲。
- 在关键路径上有启动开销。


在 GPU 上评估条件?

Page 68
Page 68

一个自然的想法是:如果我们可以在 GPU 上评估条件会怎么样?


朴素的 GPU 条件评估方法

无条件地启动所有 GPU 工作,并让 GPU 在每个核函数的序言(prologue)中评估条件。

Page 69
Page 69

潜在问题:
- 不可扩展:每个核函数都需要被修改。
- 不适用于非核函数工作。


使用图封装条件工作

将依赖于运行时条件的工作封装到一个条件节点的体图 (body-graph of a conditional node) 或一个设备启动的图 (device-launched graph) 中。

Page 70
Page 70

启用条件执行总结

Page 71
Page 71
方法 优点 缺点
返回 CPU,评估条件并启动相应工作 无需修改 GPU 核函数。 CPU 等待 GPU 完成;无法远超前;GPU 时间线出现间隙;关键路径上有启动开销。
无条件启动所有 GPU 工作,并在 GPU 上评估条件 CPU 不在关键路径上。无 GPU 间隙。 需要修改每个受影响核函数的序言以提前退出。扩展性差。核函数的序言应该总是执行。不适用于非核函数工作。
将条件工作封装到条件节点的体图或设备启动的图中 CPU 不在关键路径上。无 GPU 间隙。无需修改 GPU 核函数;工作不限于核函数。 可能需要添加额外的 join/fork 图节点。

条件图节点 (Conditional Graph Nodes)

概述

Page 72
Page 72

条件节点类型及其体图

Page 73
Page 73

控制条件

通过 cudaGraphConditionalHandle 访问条件。

Page 74
Page 74
cudaGraphConditionalHandle cond_handle;
cudaGraphConditionalHandleCreate(&cond_handle, graph, default_value, flags);
- `graph`: 使用 `cudaGraphCreate()` 创建的图。
- `default_value`: 可选,应用于每次图启动。
- `flags`: 0 (无默认值) 或 `cudaGraphCondAssignDefault` (使用默认值)。
__global__ void upstream_kernel(cudaGraphConditionalHandle handle, unsigned int new_cond_value, ...) {
    if (threadIdx.x == 0) {
        cudaGraphSetConditional(handle, new_cond_value); // device only function
    }
}

创建一个条件节点

Page 75
Page 75
  1. 为此图创建一个条件句柄
cudaGraphConditionalHandle cond_handle;
cudaGraphConditionalHandleCreate(&cond_handle, graph, default_value, flags);
  1. 创建并添加一个与此句柄关联的条件节点到图中
cudaGraphNodeParams params = {cudaGraphNodeTypeConditional};
params.conditional.handle = cond_handle; // 之前创建的句柄
params.conditional.type = cudaGraphCondTypeIf; // 或 ...While, ...Switch
params.conditional.size = 1; // 体图的数量,取决于节点类型
cudaGraphAddNode(&cond_node, graph, cond_node_deps.data(), cond_node_deps.size(), &params);
  1. 填充条件节点的体图,通过 params.conditional.phGraph_out[i] 访问。
// 将节点添加为 cond 的体图的根节点
cudaGraphAddNode(&node, params.conditional.phGraph_out[0], nullptr, 0, &nodeParams);
  1. 确保条件由一个上游核函数的一个线程填充

设备图 (Device Graphs)

概述

参考文献:
[1] https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__GRAPH.html#group__CUDART__GRAPH_1g0b72834c2e8a3c93c443c6c67626d0d9
[2] https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#device-graph-creation

Page 76
Page 76

CUDA Graphs 进一步阅读

Page 77
Page 77

MIG, MPS, 和 Green Contexts

GPU 资源分区机制

Page 78
Page 78

动机

多实例 GPU (Multi-Instance GPU, MIG) 的案例

Page 79
Page 79

多进程服务 (Multi-Process Service, MPS) 的案例

Page 80
Page 80

绿色上下文 (Green Contexts, GCs) 的案例

Page 81
Page 81

多租户选项 (单 GPU)

资源分区机制(可以组合使用)

机制 MIG (多实例 GPU) MPS (多进程服务) Green Contexts (绿色上下文)
示例 MIG 示例, Page 82 MPS 示例, Page 82 Green Contexts 示例, Page 82
类型 静态地将 GPU 分区为多个 MIG 实例(“较小的 GPU”) 动态分区 SMs 的静态分区
目标 不同的应用程序可以使用不同的 MIG 实例 主要针对不同的进程 分区发生在应用程序内部
配置 在应用程序启动前配置 需要 MPS 服务运行 在应用程序启动前无需额外服务或配置

多实例 GPU (MIG)

概述

MIG 架构分区示意图, Page 83
MIG 架构分区示意图, Page 83

如何使用

MIG 配置文件列表, Page 84
MIG 配置文件列表, Page 84
列出 MIG 设备, Page 84
列出 MIG 设备, Page 84

总结

项目 描述
分区类型 静态(仅 GPU 资源;不包括 PCI-e)
何时启用/配置 在原始 GPU 上启动任何应用程序之前
配置选项 使用的 MIG 配置文件;会影响应用程序性能
是否需要更改应用程序
使用案例 多用户或单用户运行不同应用程序且 GPU 未充分利用的情况、云服务提供商 (CSPs);需要 QoS 和隔离

参考/进一步阅读:
- MIG: https://www.nvidia.com/en-us/technologies/multi-instance-gpu/
- MIG 用户指南: https://docs.nvidia.com/datacenter/tesla/mig-user-guide/index.html
- GTC 2022 演讲: "Optimizing GPU Utilization: Understanding MIG and MPS"
- NVIDIA Ampere 架构白皮书, "MIG (Multi-Instance GPU) Architecture" 部分: nvidia-ampere-architecture-whitepaper.pdf
- NVIDIA H100 Tensor Core GPU 架构, "第二代安全 MIG" 部分。

MIG 总结, Page 85
MIG 总结, Page 85

MPS (多进程服务)

Page 86
Page 86

MPS (Multi-Process Service) 概述

MPS 效果对比图, Page 87
MPS 效果对比图, Page 87

如何使用 MPS

使用 MPS 进行资源调配

何时使用

MPS 资源调配效果, Page 89
MPS 资源调配效果, Page 89

MPS 的活动线程百分比 (Active Thread Percentage)

如何设置

设置时机 影响范围 注意事项
启动 MPS 控制守护进程之前 所有未来的 MPS 客户端 通过 sudo -E echo get_default_active_thread_percentage | sudo -E nvidia-cuda-mps-control 查询默认值
启动 MPS 客户端时 CUDA_MPS_ACTIVE_THREAD_PERCENTAGE=80 ./app 该客户端进程 限制不能大于 MPS 控制守护进程强制执行的限制
Page 90
Page 90

Nsight Systems 分析说明:时间切片与 GPU 指标

Page 91, Nsight Systems 时间线视图,展示了时间切片行为
Page 91, Nsight Systems 时间线视图,展示了时间切片行为

MPS 总结

Page 92, MPS 特性总结表
Page 92, MPS 特性总结表

参考文献/进一步阅读


Green Contexts

Page 93, Green Contexts 章节标题页
Page 93, Green Contexts 章节标题页

Green Contexts (GCs) 概述

Page 94, Green Contexts GPU 利用率示意图
Page 94, Green Contexts GPU 利用率示意图

Green Contexts vs. MPS 对比

Green Contexts: 设备资源与资源描述符

struct {
    CUdevResourceType type; // enum with CU_DEV_RESOURCE_TYPE_INVALID=0, CU_DEV_RESOURCE_TYPE_SM=1
    union {
        CUdevSmResource sm; // struct with unsigned int smCount
    };
};

Green Context 创建示例:概述

Green Context 创建:步骤 1 - 获取可用的 SM 资源

Page 98, 获取可用 SM 资源的示例代码
Page 98, 获取可用 SM 资源的示例代码

Green Context 创建:步骤 2 - 分割 SM 资源

Page 99, 分割 SM 资源的流程图
Page 99, 分割 SM 资源的流程图
Page 100, 分割 SM 资源的参数说明和示例表
Page 100, 分割 SM 资源的参数说明和示例表
Page 101, 分割 SM 资源的示例代码
Page 101, 分割 SM 资源的示例代码

Green Context 创建:步骤 3 - 生成描述符

Page 102, 生成资源描述符的示例代码
Page 102, 生成资源描述符的示例代码

Green Context 创建:步骤 4 - 创建上下文

Page 103, 创建 Green Context 的示例代码
Page 103, 创建 Green Context 的示例代码

Green Contexts: 启动工作

Page 104, 在 Green Context 上启动工作的两种方式示例代码
Page 104, 在 Green Context 上启动工作的两种方式示例代码

更多 Green Contexts 驱动 API


Green Contexts 示例

静态资源分区使关键工作能够更早地开始和完成。

示例时间线:

长时间运行的内核代理是一个延迟内核,其中每个 CTA(Cooperative Thread Array)运行 delay_us,并且 CTA 的总数大于 SM(Streaming Multiprocessor)的总数。

示例运行场景对比

Page 106
Page 106

代码将在此处提供

Nsight Systems 时间线(无 Green Contexts)

下图展示了在没有使用 Green Contexts 的情况下,critical_kernel(高优先级)的启动被 delay_kernel_us 阻塞,导致了约 0.9ms 的“损失时间”。delay_kernel 运行约 10ms,而 critical_kernel 运行约 50us。

Nsight Systems Timeline without Green Contexts. Page 107
Nsight Systems Timeline without Green Contexts. Page 107

Nsight Systems 时间线(有 Green Contexts)

通过 Green Contexts 对 GPU 资源进行分区:
- 为 critical_kernel 分配 N 个 SM,为长时间运行的内核分配 7*N 个 SM(以及一些剩余的 SM),其中 N 是在给定 Green Context 约束下支持的最大值。
- 示例展示了在 H100 上(总共 132 个 SM),N=16 的情况。

如下图所示,critical_kernel 几乎在启动后立即执行,几乎没有损失时间。其执行时间约为 95us。而 delay_kernel 的执行时间增加到约 12ms(比之前增加了 2ms),因为它使用了更少的 SM。

Nsight Systems Timeline with Green Contexts. Page 108
Nsight Systems Timeline with Green Contexts. Page 108

在 Nsight Compute 中显示的 Green Context 资源

Green Context Resources shown in Nsight Compute. Page 109
Green Context Resources shown in Nsight Compute. Page 109

Green Contexts 总结

特性 描述
分区类型 静态 (SMs)
何时启用/配置 在应用程序内部,启动工作之前。
配置选项 SM 数量和 SM 重叠由分区方式决定。
是否需要应用程序更改 是的,但仅在内核/GPU 代码之外。
使用场景 具有不同工作负载类型的单个进程;需要确保关键工作有可用的 SM 资源。
GPU utilization graph. Page 110
GPU utilization graph. Page 110

参考文献:
- https://docs.nvidia.com/cuda/cuda-driver-api/group__CUDA__GREEN__CONTEXTS.html


Cluster Launch Control (集群启动控制)

Decorative image for Cluster Launch Control. Page 111
Decorative image for Cluster Launch Control. Page 111

线程块 (Thread blocks)

如何确定线程块数量?

选择 CTA 数量的两种主要方法:

  1. 基于问题大小 (Problem size - based):
    • 每个 CTA 处理固定/有限的工作量。
    • CTA 的数量与问题大小成比例。
    • 示例代码:
_global_ void kernel(float* data, float alpha, int n)
{
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n)
    data[i] *= alpha;
}
kernel<<<(n + 1023) / 1024, 1024>>>(data, alpha, n);
  1. 基于硬件资源 (HW resources - based):
    • 又称持久化内核 (persistent kernels) 或 grid-stride loop。
    • 固定/有限数量的 CTA。
    • 每个 CTA 的工作量与问题大小成比例。
    • 示例代码:
_global_ void kernel(float* data, float alpha, int n)
{
  for (int i = blockIdx.x * blockDim.x + threadIdx.x;
       i < n; i += gridDim.x * blockDim.x)
    data[i] *= alpha;
}
kernel<<<sm_count * 2, 1024>>>(data, alpha, n);

基于问题大小的线程块数量控制

Diagram showing preemption with problem size-based thread block count. Page 114
Diagram showing preemption with problem size-based thread block count. Page 114
Diagram showing load balancing. Page 115
Diagram showing load balancing. Page 115
Diagram showing scheduler overhead. Page 116
Diagram showing scheduler overhead. Page 116
Diagram showing both overheads. Page 117
Diagram showing both overheads. Page 117

持久化内核 (Persistent Kernels)

这是对基于硬件资源方法的分析。

Diagram for Persistent Kernels showing lack of preemption. Page 118
Diagram for Persistent Kernels showing lack of preemption. Page 118

自定义负载均衡 (Custom load balancing)

if (threadIdx.x == 0)
  shared_counter = bid.fetch_add(1, cuda::memory_order_relaxed);
__syncthreads();
bx = shared_counter;

reset_counter<<<1, 1>>>();
kernel<<<sm_count, 1024>>>(n_blocks);

集群启动控制 - 优缺点总结

下表总结了三种方法的优缺点:

Pros/cons summary table. Page 120
Pros/cons summary table. Page 120

概念与优势

集群启动控制(Cluster Launch Control)是一种结合了多种调度策略优点的新方法。下表比较了基于问题规模、基于硬件资源、自定义(原子计数器)以及集群启动控制这四种方法的特性。集群启动控制在抢占(Preemption)、负载均衡(Load balancing)、开销(Overhead)和易用性(Ease of use)四个方面均表现出色,实现了“两全其美”的效果。

Page 121
Page 121

核心特性:

下图通过时间线直观展示了集群启动控制的工作模式。CPU 发起两次启动(LAUNCH)操作,流式多处理器(SM)则持续处理任务,展示了动态和持续的工作分派流程,从而实现高效的负载均衡。

Page 122
Page 122

API 介绍:取消单个 CTA

集群启动控制提供了一套 API 来动态管理任务。取消一个 CTA 的基本流程如下:

  1. 从单个线程异步请求取消,并将结果存入 __shared__ 内存。
  2. 基于事务计数(transaction count),使用 __shared__ 内存屏障(barrier)同步该请求。
  3. 检查同步结果以确认操作是否成功。
  4. 从同步结果中提取被取消的 CTA 的索引。

注意:虽然可以从多个线程发起取消请求,但这在典型工作流中并不推荐,也非必需,因为取消操作本身是低延迟的。

Page 123
Page 123

API 代码示例

以下 PTX 代码展示了取消 CTA 的具体实现。代码逻辑分为前序(PROLOGUE)、线程块计算(THREAD BLOCK bx COMPUTATION)和后序(EPILOGUE)三个部分。

Page 124
Page 124

代码关键点解析

  1. 单线程发起(Single Arrival):通常由线程块中的单个线程(例如 threadIdx.x == 0)发起异步取消请求,以避免冗余操作。

    Page 125
    Page 125
  2. 基于事务计数的完成机制:同步的完成与否是通过事务计数来判断的。此处的 tx_count 基于结果数据结构 uint4 的大小,用于 mbarrier 的同步。

    Page 126
    Page 126
  3. 异步请求clusterlaunchcontrol_try_cancel 是一个异步(“in flight”)请求,它可以在前一个 CTA 仍在计算时被提交,从而实现计算和控制的重叠。

    Page 127
    Page 127
  4. 取消完成:代码通过查询 clusterlaunchcontrol_query_cancel_is_canceled 的返回值来判断取消操作是否已成功完成,并据此决定是否跳出循环。

    Page 128
    Page 128
  5. 内存栅栏(Fence)__syncthreads() 不足以保证异步代理(async proxy)操作的可见性。必须使用专门的 fence_proxy_async_generic_sync_restrict 指令来确保所有线程都能观察到异步操作的结果。

    Page 129
    Page 129

API 示例优化

  1. 双缓冲(Double-buffering):使用双缓冲(result[2]phase 变量)可以避免使用 __syncthreads() 来保护结果的覆写,从而提升性能。

    Page 130
    Page 130
  2. 避免循环剥离(Loop Peeling):使用 cg::invoke_one(cg::coalesced_threads(), ...) 代替 if (threadIdx.x == 0) 可以让所有线程执行统一的指令路径,避免了因条件分支导致的线程束发散(divergence),这是一种常见的性能优化技巧。

    Page 131
    Page 131
  3. 多维 CTA 适配:在使用一维、二维或三维 CTA 时,需要相应地调整代码逻辑,例如发起请求的线程判断以及获取 CTA ID 的方式。

    Page 132
    Page 132

API 介绍:集群(Cluster)情景

在集群范围内取消 CTA 的流程与单个 CTA 类似,但引入了多播(multicast)机制:

  1. 从集群中的任意一个 CTA 的单个线程异步请求取消。
  2. 取消结果被多播到集群中每个 CTA 的本地 __shared__ 内存中。
  3. 每个 CTA 内部使用本地 __shared__ 内存屏障进行同步。
  4. 从同步结果中提取根(root)CTA 的索引。
  5. 将本地 CTA 的偏移量添加到根 CTA 索引上,得到全局索引。
  6. (待办)在首次取消前,需通过屏障确保集群内所有 CTA 都已启动。

下图展示了一个 2x2 集群中,根 CTA (0,0) 将取消结果多播到其他 CTA 的示意图。

Page 133
Page 133

集群情景下的 API 代码示例

以下 PTX 代码展示了在集群范围内进行取消操作的实现。关键改动包括使用集群组同步 cg::cluster_group::sync()、通过 cg::cluster_group::thread_rank() == 0 选择发起线程,以及调用多播版本的取消指令 clusterlaunchcontrol_try_cancel_multicast

Page 134
Page 134

代码关键点解析
* 集群同步:在循环开始处的 cg::cluster_group::sync() 调用至关重要。它确保了在第一次迭代时集群内所有 CTA 都已准备就绪,并保护了共享数据在后续迭代中不被覆写。

Page 135

以下代码片段展示了在集群情况下使用集群启动控制 API 的一个示例。

Page 137
Page 137

集群启动控制:负载均衡示例

以下是在 NVIDIA B200 上使用 4GB 数组进行的计时测试,展示了集群启动控制在负载均衡方面的优势。

Page 138
Page 138

集群启动控制:优先级示例

传统持久化内核的问题

下图展示了传统持久化内核在处理优先级任务时的局限性。

Page 139
Page 139

使用启动控制内核的优势

下图展示了使用集群启动控制的内核如何解决优先级问题。

Page 140
Page 140

CUDA 开发者会议

该幻灯片列出了一系列与 CUDA 相关的开发者会议主题,涵盖了通用 CUDA、CUDA Python、CUDA C++、开发者工具、多 GPU 编程和性能优化等领域。

Page 141
Page 141

Page 142
Page 142