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 性能优化系列教程的一部分,该系列包含以下内容:
- 最大化内存带宽和隐藏延迟的 CUDA 技术 [S72683]
- 最大化计算和指令吞吐量的 CUDA 技术 [S72685]
- 最大化并发性和系统利用率的 CUDA 技术 [S72686]
- 在 Grace-Hopper/Blackwell 上最大化应用程序性能的 CUDA 技术 [S72687]
namespace cg = cooperative_groups;
using namespace cuda; // cuda::ptx::
CUDA/GPU 任务(如内核、异步内存操作、主机回调等)与 CPU 是异步执行的。如下图所示,CPU 启动内核后可以继续执行其他代码,而不需要等待 GPU 内核完成。在默认情况下(未使用流),GPU 上的内核是按顺序执行的。
通过使用不同的 CUDA Streams,任务可以在 GPU 上彼此并行执行。这使得 GPU 能够同时处理来自不同流的任务,从而提高利用率。
cudaSetDevice 设置)。同步 (Synchronization):
- CPU 可以与一个流同步——等待该流中所有之前的任务完成。
- 流之间也可以相互同步——一个流中的下一个任务直到另一个流中的某个特定任务完成后才开始。
¹ Programmatic Dependent Launch - 将在本演示的后面部分介绍
不同的流可以乱序或并发执行任务。
默认流 (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>>>();
移除隐式同步:
为了实现异步的默认流行为,可以使用编译器选项:
nvcc --default-stream per-threadnvcc -DCUDA_API_PER_THREAD_DEFAULT_STREAM=1 ...使用 cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking) 创建的流不与默认流同步。
建议:
- 避免使用默认流。
- 使用 cudaStreamNonBlocking 创建显式流。
流可以拥有优先级。
- 支持的范围: cudaDeviceGetStreamPriorityRange(...)
- 带优先级的流: cudaStreamCreateWithPriority(...)
优先级为调度任务提供了提示。
- 例如,较高优先级的 CTA 将在已经运行的较低优先级 CTA 完成后立即运行,而剩余的较低优先级 CTA 将在较高优先级的 CTA 完成后运行。
下图展示了将 kernel1_w 启动到低优先级流,随后将 kernel1_h 启动到高优先级流。尽管 kernel1_w 先启动,但 kernel1_h 会抢先执行。
未来的 Nsight Systems 版本将可以直接在 profiler 界面中查看到流的优先级(通过鼠标悬停在流上)。
重度同步 (Heavy synchronization):
cudaDeviceSynchronize() 会阻塞 CPU,直到所有流上的所有 GPU 任务都完成。
这会导致 GPU 任务之间出现空闲,因为 CPU 无法提前提交新的任务,从而产生“启动延迟间隙 (launch latency gap)”。
一个常见的不良实践是在每次内核启动后调用 cudaDeviceSynchronize()(通常仅为了检查错误或计时)。这会严重影响性能,因为它完全消除了 CPU 和 GPU 之间的并发性。
某些 API 调用是完全阻塞和同步的——如同被 cudaDeviceSynchronize() 包围:
cudaFree(...)
其他一些 API 调用也可以是完全阻塞和同步的——如同被 cudaDeviceSynchronize() 包围:
cudaMemcpy(...),包括 cudaMemcpyHostToHost、cudaMemcpyHostToDevice、cudaMemcpyDeviceToHost。
一些其他 API 函数,如 cudaDeviceSetCacheConfig(...) 也具有类似行为。
注:非参考标准,行为可能因硬件、页锁定/非页锁定内存、大小而异。
cudaMemcpy(...) (例如 cudaMemcpyDeviceToDevice)cudaMemset(...)下图展示了 API 调用(CPU 时间线上的红色方块)是非阻塞的,但它在默认流中是同步的,即它会等待流中先前的任务完成后才开始执行(GPU 时间线上的红色圆圈)。
cudaMalloc(...)下图显示 API 调用在 CPU 上是阻塞的(红色圆圈),但 GPU 上的内核执行是异步的,可以继续进行。
注:非参考标准,行为可能有所不同。
通过使用 Async(异步)操作来避免重度同步。
cudaMemcpyAsync, cudaMemsetAsync, cudaMallocAsync, cudaFreeAsynccudaMemcpyAsync。具有流语义的 CPU 任务:cudaLaunchHostFunc(stream, host_fn, data_ptr)。
内核启动错误 (Kernel<<<...>>> launch error):
cudaGetLastError() [重置错误], cudaPeekAtLastError() [不重置错误] 来捕获。cudaDeviceSynchronize(), 等)。执行错误 (Execution error):
cudaGetLastError() 和 cudaPeekAtLastError() 不会报告此错误。cudaDeviceSynchronize(), cudaStreamSynchronize(...), cudaEventSynchronize(...) 报告。下图展示了两种错误检查方式的区别:
cudaGetLastError() 在此之后调用不会报告错误,因为它只检查启动错误。只有在 cudaDeviceSynchronize() 处才会捕获到执行错误。cudaDeviceSynchronize() 能够正确捕获到异步发生的执行错误,而随后的 cudaGetLastError() 不会再次报告该错误。cudaGetLastError() / cudaPeekAtLastError()。常用的 cudaMemcpy/cudaMemcpyAsync:
用于批量复制的新 API (CUDA 12.0):
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,直到先前提交到该流的所有任务完成。
cudaEventRecord(...) 会重置完成状态。事件同步:
cudaEventSynchronize(...) - 阻塞直到完成。cudaEventQuery(...) - 非阻塞的完成状态检查。可以获取完成时间戳:
cudaEventElapsedTime(start_event, stop_event) - 计算两个已完成事件之间的时间。由于数据依赖性,同步是必需的。
最常见的情况:
流1中的任务消费由流2中的任务产生的数据(流1中的任务在流2中的任务完成后开始消费数据)。
cudaStreamWaitEvent(stream, event_from_other_stream):
该示例展示了如何处理一个需要将一维CPU数组转换为二维CPU数组的任务,其中输出的每一列都是独立计算的。
处理流程的目标是,以一个一维CPU数组为输入,经过计算后,生成一个二维CPU数组,其中每一列都是独立计算的。
基础的串行处理流程如下:
1. 循环处理每一列:
1. 步骤 1: 将当前列的数据从CPU复制到GPU。
2. 步骤 2: 在GPU上执行计算。
3. 步骤 3: 将计算结果从GPU复制回CPU。
为了提升性能,核心目标是让计算和数据复制操作能够重叠(并行)执行。理想情况下,当GPU在计算第 i+1 列时,数据传输硬件可以同时在复制第 i 列的结果。
为了实现异步的设备到主机(D2H)数据传输,通常需要使用固定(Pinned)内存作为中转缓冲区。
改进后的流程分为两个复制步骤:
注:
- 在X86架构上,需要通过固定缓冲区进行复制。
- 在Grace Hopper/Grace Blackwell (GH/GB) 架构上,如果使用 cudaMemcpyBatchAsync(...),则可能不需要固定缓冲区。
为了进一步提高并行度,可以使用多个CUDA流。例如,可以创建两个独立的流,一个处理所有奇数索引的列,另一个处理所有偶数索引的列,从而形成两条并行的处理流水线。
处理偶数列的流水线:
处理奇数列的流水线:
使用NVIDIA Nsight Systems工具可以观察到,通过流技术,内存复制(Memcpy DtoH, memcpy_h2h)和内核计算(void runna...)操作可以在时间上重叠执行。
异步CPU任务(如H2H复制)通常可以通过专用的CPU线程和同步原语(如互斥锁)来实现。但另一种更高效的方法是使用CUDA Streams结合 cudaLaunchHostFunc(...),它允许将一个CPU函数调用插入到CUDA流中,由CUDA运行时在适当的时候回调执行。
一个更精细的实现是使用三个独立的流来管理流水线的不同阶段:
- stream_cpt:用于GPU计算。
- stream_d2h:用于设备到主机(固定内存)的异步复制。
- stream_h2h:用于主机端的回调函数,执行从固定内存到最终目标内存的复制。
代码实现框架:
在循环中,为每一列提交异步任务到各自的流中。使用 [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);
}
主机回调函数:
cudaLaunchHostFunc 调用的主机函数 fn_h2h 负责执行从固定内存到最终输出数组的 memcpy 操作。参数通过一个结构体 Pars_h2h 传递。
为了确保流水线中各个阶段按正确的依赖关系执行(例如,必须在计算完成后才能开始复制结果),需要使用CUDA事件进行流间的显式同步。
stream_d2h)必须等待计算(stream_cpt)完成。stream_h2h)必须等待D2H复制(stream_d2h)完成。stream_cpt)需要等待前一轮的D2H复制完成。带事件同步的完整代码:
通过 cudaEventRecord 记录事件,并通过 cudaStreamWaitEvent 来让一个流等待另一个流中的事件。
cudaDeviceSynchronize()。cudaStreamNonBlocking 标志创建流,并避免使用默认流(默认流具有同步行为)。cudaMemcpyBatchAsync。cudaEventDisableTiming 标志创建事件以获得更好的性能。CUDA_DEVICE_MAX_CONNECTIONS:限制计算和复制引擎的并发连接数。CUDA_DEVICE_MAX_COPY_CONNECTIONS:限制复制引擎的并发连接数。CUDA_SCALE_LAUNCH_QUEUES:启动队列大小的缩放因子。在标准的 CUDA 流执行模型中,流语义保证了内核的顺序执行。然而,内核之间的数据依赖关系通常是隐式的。如下图所示,Consumer kernel 3 依赖于 Producer 内核(primary 和 secondary)产生的数据。
Consumer kernel 的执行通常可以分为两个阶段:
Producer 内核的输出。例如,共享内存初始化、指针运算、其他设置工作、从全局内存读取只读数据等。Producer 内核生成的数据。在传统的流执行中,Consumer kernel 必须等待 Producer 完全结束后才能开始,即使其前序部分可以提前执行。
Programmatic Dependent Launch (PDL) 允许 consumer kernel 的前序部分与 producer kernel 的执行机会性地重叠,从而提高硬件利用率。
PDL 通过在生产者和消费者内核中插入特定的设备 API 来协调它们的执行。
关键时间点:
- 在 primary (生产者) 内核中,指示 secondary (消费者) 内核可以被触发的时间点。
- 在 secondary (消费者) 内核中,指示内核应该阻塞并等待 primary 内核完成的时间点。
设备 API (适用于 CC >= 9.0):
- cudaTriggerProgrammaticLaunchCompletion
- 位置:在 primary (生产者) 内核中调用。
cudaGridDependencySynchronizesecondary (消费者) 内核中调用。约束条件:
secondary (消费者) 内核必须通过 cudaLaunchKernelEx 启动。primary 和 secondary 内核之间不能存在任何其他 GPU 工作(例如,不能有 CUDA 事件记录)。下图展示了如何在主机端(CPU)代码中启动使用 PDL 的内核。
__global__ void primary_kernel(uint8_t* d_ptr);
__global__ void secondary_kernel(uint8_t* d_ptr);
primary_kernel 的启动方式保持不变。primary_kernel<<<grid_dim, block_dim, 0, strm>>>(d_ptr);
- `secondary_kernel` 的启动需要使用 `cudaLaunchKernelEx`。
- 首先,配置常规的启动参数(`blockDim`, `gridDim`, `dynamicSmemBytes`, `stream`)。
- 然后,需要设置一个特殊的属性 `cudaLaunchAttributeProgrammaticStreamSerialization`,并将其值 `programmaticStreamSerializationAllowed` 设为 1。
- 最后,使用 `cudaLaunchKernelEx` 启动内核。
下图展示了如何在设备端(GPU)内核代码中使用 PDL API。
primary_kernel (生产者):
__global__ void primary_kernel(uint8_t* d_ptr) {
work_A();
cudaTriggerProgrammaticLaunchCompletion();
work_B();
}
secondary_kernel 可以在以下任一条件满足时被调度:
primary 内核中每个未退出的 CTA(Cooperative Thread Array)都至少调用了一次 cudaTriggerProgrammaticLaunchCompletion() API。primary 内核中没有未退出的 CTA 调用该 API,则 secondary_kernel 可以在 primary 的所有 warp 完成后被调度。cudaTriggerProgrammaticLaunchCompletion() 不提供内存可见性保证。
secondary_kernel (消费者):
__global__ void secondary_kernel(uint8_t* d_ptr) {
work_C();
cudaGridDependencySynchronize();
work_D();
}
cudaGridDependencySynchronize 时会阻塞,并等待 primary 内核完成(包括 work_B)。work_C 应该可以安全地与 work_B 并行执行。work_C 不应与 primary_kernel 的工作(work_A 或 work_B)有数据依赖。例如:
work_A 或 work_B 修改的数据。work_A 或 work_B 访问的数据。work_A 或 work_B 读取的只读数据是可以的。典型的 work_C 包括:局部计算、共享内存初始化、从全局内存读取只读数据等。
work_D 通常处理由 primary 内核产生的数据。过早触发 cudaTriggerProgrammaticLaunchCompletion 的性能考量?
duration(work_B) > duration(work_C),那么 secondary_kernel 可能会长时间等待 cudaGridDependencySynchronize()。secondary_kernel 占用的 SMs 时尤为重要。如果在 work_C 之后调用的 cudaGridDependencySynchronize() 不正确地访问了由 work_B 修改的数据会怎样?
secondary_kernel 中,对于常见用例,在任何全局内存访问之前调用 cudaGridDependencySynchronize。如果 work_B 和 work_C 是空的会怎样?
兼容性:
#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)在更复杂的依赖链中,一个内核可以同时作为消费者和生产者。
primary_kernel 是生产者。secondary_kernel 是 primary_kernel 的消费者,同时也是 another_kernel 的生产者。another_kernel 是 secondary_kernel 的消费者。下图展示了不同情况下 PDL 的 GPU 执行时间线。
secondary_kernel 在 primary_kernel 完全结束后才开始执行。PDL (所有 CTA 调用 trigger): secondary_kernel 在 primary_kernel 执行期间就开始了,实现了重叠。
primary 是多波次(multi-wave)执行的,secondary 只有在最后一波 CTA 调用了 trigger 后才能被调度。PDL (隐式触发): 当 secondary 在末尾被隐式触发时(例如,没有 CTA 调用 trigger),也会有执行重叠。
代码示例可以在 https://github.com/NVIDIA/cuda-samples/tree/master/Samples/5_Domain_Specific/programmaticLaunch 找到。
cudaTriggerProgrammaticLaunchCompletion 和 cudaGridDependencySynchronize。操作类型:
依赖关系:
性能优势:
CPU 时间线 (包括 CUDA API 调用):
cudaGraphLaunch 单次调用即可启动 CUDA graph 中封装的所有工作,从而节省了 CPU 时间。让 CPU 领先于 GPU,以避免因 CPU 成为关键路径而导致的 GPU 空闲。对于短内核,CPU 开销的影响更为显著。
使用传统的流和事件来实现与上图相同的依赖关系会非常复杂。
event_end_A,然后让 stream 2 等待 event_end_A。event_end_C,然后让 stream 1 等待 event_end_C。CUDA Graphs 抽象了这种复杂的依赖管理。
下图比较了第二次启动相同工作时,基于流和基于图的 CPU 和 GPU 时间线。
CPU 时间线:
cudaGraphLaunch 调用,CPU 开销极低,节省了大量时间。GPU 时间线:
Graph-4 (GraphExec 1)) 出现。注意: 时间线来自 Nsight Systems,使用默认的 --cuda-graph-trace graph 模式。节点级别的追踪可以通过 --cuda-graph-trace node 实现,但这可能会带来显著的开销。
定义图 (Define graph)
cudaGraph_t 图中。实例化图 (Instantiate graph)
cudaGraphExec_t。cudaGraphInstantiate(&graph_exec, graph)启动图 (Launch graph)
cudaGraphLaunch(graph_exec, stream)cudaGraphUpload(graph_exec, stream)后续操作
cudaGraphExecKernelNodeSetParams 从 CPU 端更新节点。cudaGraphNodeSetEnabled() 禁用节点。销毁图 (Destroy graph)
通过捕获在 CUDA 流上执行的工作来创建图。
代码示例:
cudaStreamBeginCapture(strm1, cudaStreamCaptureModeGlobal): 开始在 strm1 上捕获操作,以构建图。kernel_A<<<1, 32, 0, strm1>>>(): 在 strm1 上启动核函数 A,此操作被捕获到图中。cudaEventRecord(event_end_A, strm1): 记录事件,同样被捕获。kernel_C<<<1, 32, 0, strm2>>>(): 在 strm2 上启动核函数 C。这里 strm2 是捕获的一部分,它会派生/加入依赖于 strm1。cudaStreamEndCapture(strm1, &graph): 结束在 strm1 上的捕获,并将捕获到的操作序列整合成一个图对象 graph。要点:
- 在流捕获期间,GPU 不执行工作;工作仅被捕获到图中。
- 重要提示:在核函数启动后(包括捕获期间),不要跳过 cudaGetLastError() 调用。如果核函数启动包含无效参数(例如,不支持的网格大小、动态共享内存等),你可能会在图中遇到静默丢失的核函数。
- 免责声明:为简洁起见,幻灯片中的代码示例省略了错误检查代码。
手动、显式地创建图节点并定义它们之间的依赖关系。
代码示例:
1. 创建图和节点:
cudaGraph_t graph;
cudaGraphCreate(&graph, 0);
cudaGraphNode_t node_A, node_B, node_C, node_D, node_E;
cudaKernelNodeParams params[5] = {};
// <...> 填充核函数节点参数
cudaGraphAddKernelNode(&node_A, graph, nullptr, 0, ¶ms[0]);
- `&node_A`: 要添加的图节点。
- `graph`: 要添加到的图。
- `nullptr`, `0`: 该节点的依赖项数量(0 表示是根节点)。
- `¶ms[0]`: 参数。
cudaGraphAddKernelNode(&node_B, graph, &node_A, 1, ¶ms[1]);
- `&node_A`, `1`: `node_B` 依赖于 `node_A`。
代码示例(续):
node_C 同样依赖于 node_A。node_D 依赖于 node_B 和 node_C:std::vector<cudaGraphNode_t> node_deps = {node_B, node_C};
cudaGraphAddKernelNode(&node_D, graph, node_deps.data(), node_deps.size(), ¶ms[3]);
node_E 依赖于 node_D。这取决于具体情况,需要考虑一些权衡:
流捕获 (Stream Capture)
优点:
缺点:
cudaLaunchHostFunc)。使用图 API 创建图(手动)
优点:
缺点:
这是一个远未完整的列表。
cudaGraphDebugDotPrint:用于可视化图。
cudaGraphDebugDotPrint(graph, "graph", 0 /*或 cudaGraphDebugDotFlagsVerbose */)cudaGraphGetNodes:获取图中节点的数量或列表。
cudaGraphNodeGetType, cudaGraphKernelNodeGetParams 等一起使用。cudaGraphExecKernelNodeSetParams:更新图执行实例中核函数节点的参数。
cudaGraphNodeSetEnabled:在图执行中启用/禁用一个节点。处理过程可能依赖于在某些(GPU)工作处理之后才知道的运行时条件。
示例:
- 如果你的数据具有某些特征,则进行额外的处理。
- 如果你已经得到了一个足够好的答案,跳过后续的处理。
- 如果你的处理时间过长,提前退出。
CPU 评估条件并决定接下来要启动什么。
如上图所示,当控制流返回到 CPU 进行条件判断时:
1. CPU 启动初始数据处理。
2. CPU 等待其完成。
3. CPU 评估条件 A,然后启动算法 1。
4. CPU 再次等待完成。
5. CPU 评估条件 B,然后启动后续工作。
潜在问题:
- CPU 无法远超前于 GPU。
- GPU 时间线上出现间隙 (Gap),导致 GPU 空闲。
- 在关键路径上有启动开销。
一个自然的想法是:如果我们可以在 GPU 上评估条件会怎么样?
无条件地启动所有 GPU 工作,并让 GPU 在每个核函数的序言(prologue)中评估条件。
GPU 时间线:
问题: 如果处理不仅仅是核函数(例如,包含 memcpy 操作)怎么办?
潜在问题:
- 不可扩展:每个核函数都需要被修改。
- 不适用于非核函数工作。
将依赖于运行时条件的工作封装到一个条件节点的体图 (body-graph of a conditional node) 或一个设备启动的图 (device-launched graph) 中。
| 方法 | 优点 | 缺点 |
|---|---|---|
| 返回 CPU,评估条件并启动相应工作 | 无需修改 GPU 核函数。 | CPU 等待 GPU 完成;无法远超前;GPU 时间线出现间隙;关键路径上有启动开销。 |
| 无条件启动所有 GPU 工作,并在 GPU 上评估条件 | CPU 不在关键路径上。无 GPU 间隙。 | 需要修改每个受影响核函数的序言以提前退出。扩展性差。核函数的序言应该总是执行。不适用于非核函数工作。 |
| 将条件工作封装到条件节点的体图或设备启动的图中 | CPU 不在关键路径上。无 GPU 间隙。无需修改 GPU 核函数;工作不限于核函数。 | 可能需要添加额外的 join/fork 图节点。 |
一个条件节点包含:
时间线:
条件节点 B 执行:
核函数 C 在适用的体图完成之后执行。
cond != 0 时执行。需要 1 个体图。cond != 0 时执行一个分支,cond == 0 时执行另一个。需要 2 个体图。cond != 0 时循环执行。需要 1 个体图。cond 的值选择一个分支执行。需要 N 个体图(对应 N 个 case 语句)。通过 cudaGraphConditionalHandle 访问条件。
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
}
}
cudaGraphConditionalHandle cond_handle;
cudaGraphConditionalHandleCreate(&cond_handle, graph, default_value, flags);
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(), ¶ms);
params.conditional.phGraph_out[i] 访问。// 将节点添加为 cond 的体图的根节点
cudaGraphAddNode(&node, params.conditional.phGraph_out[0], nullptr, 0, &nodeParams);
通过在一个特殊流上的图中的内核调用 cudaGraphLaunch 来启动设备图
cudaStreamGraphFireAndForget, cudaStreamGraphTailLaunch, cudaStreamGraphFireAndForgetAsSibling设备图:
cudaGraphInstantiate) 期间需要一个特殊标志 cudaGraphInstantiateFlagDeviceLaunch与主机图相比有额外的限制
在从设备启动之前需要上传到设备
cudaGraphUpload,作为实例化的一部分通过特殊标志上传,或从主机进行一次额外的启动。参考文献:
[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
NVIDIA 技术博客文章和 GTC 演讲 (非完整列表):
相关的 CUDA 示例:
多实例 GPU (Multi-Instance GPU, MIG) 的案例
多进程服务 (Multi-Process Service, MPS) 的案例
绿色上下文 (Green Contexts, GCs) 的案例
资源分区机制(可以组合使用)
| 机制 | MIG (多实例 GPU) | MPS (多进程服务) | Green Contexts (绿色上下文) |
|---|---|---|---|
| 示例 | |||
| 类型 | 静态地将 GPU 分区为多个 MIG 实例(“较小的 GPU”) | 动态分区 | SMs 的静态分区 |
| 目标 | 不同的应用程序可以使用不同的 MIG 实例 | 主要针对不同的进程 | 分区发生在应用程序内部 |
| 配置 | 在应用程序启动前配置 | 需要 MPS 服务运行 | 在应用程序启动前无需额外服务或配置 |
一个 MIG 配置文件 (MIG Xg.Ygb) 由 X 个计算切片 (SMs) + 内存切片 (L2, Mem.) 和 Y GB 总内存组成。
MIG 提供(在 MIG 实例之间):SM 性能隔离、错误隔离、内存带宽 QoS、内存保护。
为 GPU 启用 MIG 模式:如果支持,使用 sudo nvidia-smi -i <GPU> -mig 1
列出支持的 GPU 实例配置文件:nvidia-smi mig -lgip
sudo nvidia-smi mig -cgi <profile ID1, ID2, ...> -Cnvidia-smi -L在特定实例上运行:
CUDA_VISIBLE_DEVICES=1 ./example_app 或 CUDA_VISIBLE_DEVICES=MIG-<UUID> ./example_app销毁所有 MIG 实例:sudo nvidia-smi mig -dci && sudo nvidia-smi mig -dgi 并禁用 MIG 模式。
| 项目 | 描述 |
|---|---|
| 分区类型 | 静态(仅 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" 部分。
MPS 允许多个进程同时在 GPU 上运行,无需时间分片。
默认情况下,没有资源的静态分区或 QoS。
没有错误隔离(与 MIG 不同)。
每个物理 GPU 最多支持 48 个 MPS 客户端 (Volta+),具体取决于:
CUDA_DEVICE_MAX_CONNECTIONS 环境变量、每个客户端的内存需求等。要使用 MPS,请在启动 MPS 客户端之前启动 MPS 守护进程。
设置适当的环境变量:
export CUDA_VISIBLE_DEVICES=0 选择要使用的 GPU;0;也可以指定 GPU-UUID (包括 MIG 实例)。export CUDA_MPS_PIPE_DIRECTORY=<accessible pipe path> 默认目录是 /tmp/nvidia-mps。export CUDA_MPS_LOG_DIRECTORY=<accessible log path> 默认目录是 /var/log/nvidia-mps。建议将相关 GPU 计算模式设置为 exclusive:
sudo nvidia-smi -i 0 -c EXCLUSIVE_PROCESS-c 或 --compute-mode 选项是 0/DEFAULT, 2/PROHIBITED, 3/EXCLUSIVE_PROCESS。启动守护进程并利用 MPS:
sudo nvidia-cuda-mps-control -dnvidia-cuda-mps-server 进程,在 nvidia-smi 下。sudo echo quit | sudo nvidia-cuda-mps-control。从 CUDA 12.4 开始,您可以以编程方式检查此进程是否为 MPS 客户端,通过 mpsEnabled = 0; cuResult res = cuDeviceGetAttribute(&mpsEnabled, CU_DEVICE_ATTRIBUTE_MPS_ENABLED, device);
res = CUDA_SUCCESS,如果此进程是 MPS 客户端,mpsEnabled 将为 1。何时使用
默认情况下,不同的 MPS 客户端会竞争所有 GPU 资源,如 SMs、内存等。
MPS 资源调配(活动线程百分比)对客户端进程可以使用的 SM 百分比设置了上限。
如何设置
CUDA_MPS_ACTIVE_THREAD_PERCENTAGE 环境变量设置(最高 100.0)。| 设置时机 | 影响范围 | 注意事项 |
|---|---|---|
| 启动 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 控制守护进程强制执行的限制 |
cudaDevAttrMultiProcessorCount 属性将显示此进程的 活动线程百分比 * GPU 总 SMs 限制。gpucxtsw=true 选项来观察时间切片(time-slicing)行为(在没有 MPS 且使用默认计算模式的情况下)。[22934] 和 [22935])在时间上交替运行。同时,SMs Active(活跃 SMs)指标也显示了 GPU 利用率的变化。分析命令示例:
nsys profile --gpucxtsw=true --gpu-metrics-devices=0 ./launch_script 收集数据。sudo nvidia-smi compute-policy --set-timeslice 0 (可能的值为 {0, 3})。SMs Active 指标解读:
nvidia-smi 显示的 GPU 利用率(GPU Utilization)表示“在过去采样周期内,一个或多个内核在 GPU 上执行的时间百分比”。即使只有一个内核在单个 SM 上运行,这个值也可能是 100%。启用/配置时机(When to enable/configure):
配置选项(Config. Options):
CUDA_MPS_PINNED_DEVICE_MEM_LIMIT 用于限制可分配的固定设备内存量)。是否需要应用程序更改(Application changes needed):
使用场景(Use cases):
Green Contexts 功能通过 CUDA Driver API (-lcuda) 提供。
本节中的 GC 示例将假定使用 CUDA 12.8。
MPS 主要针对不同的进程,而 Green Contexts 针对单个进程。
假设 MPS 设置了 80% 的活动线程百分比,而 Green Context 设置了 80 个 SMs 作为可用资源(GPU 共有 100 个 SMs)。
过度订阅(Oversubscribed)示例:3 个进程或 GCs(GPU 仍有 100 个 SMs)。
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 创建之后:
<<<...>>> 语法启动的内核或使用任何 CUDA 驱动/运行时 API 的操作。获取我们可以分区的 GPU SM 资源,并填充 CUdevResource 结构体。
CUresult cuDeviceGetDevResource(CUdevice device, CUdevResource* resource, CUdevResourceType type)CUresult cuCtxGetDevResource(CUcontext hCtx, CUdevResource* resource, CUdevResourceType type)CUresult cuGreenCtxGetDevResource(CUgreenCtx hCtx, CUdevResource* resource, CUdevResourceType type)通常,您的起点将是 GPU 设备。
cuDevSmResourceSplitByCount() API 静态地将可用的 CUdevResource SM 资源分割成一个或多个同构分区,可能还会留下一些 SMs 在剩余分区中。API 调用:
CUresult cuDevSmResourceSplitByCount(CUdevResource* result, unsigned int* nbGroups, const CUdevResource* input, CUdevResource* remaining, unsigned int useFlags, unsigned int minCount)
*nbGroups 个同构组,每组包含 minCount 个 SMs。*nbGroups(可能小于请求值),每个组包含 N 个 SMs(N >= minCount)。API 详细说明:cuDevSmResourceSplitByCount(...)
*nbGroups (>= 1):
minCount (>= 0):
N)可能会因粒度(granularity)和最小值的要求而更大。useFlags 更改)。result=nullptr 来查询可以创建的组数。remaining=nullptr。API 调用:CUresult cuDevResourceGenerateDesc(CUdevResourceDesc* phDesc, CUdevResource* resources, unsigned int nbResources)
示例:生成一个封装了 3 组资源的资源描述符。
num_resources=1)。CUcontext,然后将其设置为当前上下文并提交工作。CUresult cuGreenCtxRecordEvent(CUgreenCtx hCtx, CUevent hEvent)
cudaEventRecord 有何不同?CUresult cuGreenCtxWaitEvent(CUgreenCtx hCtx, CUevent hEvent)
cudaStreamWaitEvent。CUresult cuStreamGetGreenCtx(CUstream hstream, CUgreenCtx* phCtx)
phCtx 更新为与 hstream 关联的 Green Context(如果有),否则设置为 NULL。CUresult cuGreenCtxDestroy(CUgreenCtx hCtx)
静态资源分区使关键工作能够更早地开始和完成。
示例时间线:
strm1 上启动一个 长时间运行的内核 (delay_kernel_us),该内核在整个 GPU 上占用多个波次。strm2 上启动一个 较短的关键内核 (critical_kernel)。长时间运行的内核代理是一个延迟内核,其中每个 CTA(Cooperative Thread Array)运行 delay_us,并且 CTA 的总数大于 SM(Streaming Multiprocessor)的总数。
示例运行场景对比
代码将在此处提供
下图展示了在没有使用 Green Contexts 的情况下,critical_kernel(高优先级)的启动被 delay_kernel_us 阻塞,导致了约 0.9ms 的“损失时间”。delay_kernel 运行约 10ms,而 critical_kernel 运行约 50us。
通过 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。
| 特性 | 描述 |
|---|---|
| 分区类型 | 静态 (SMs) |
| 何时启用/配置 | 在应用程序内部,启动工作之前。 |
| 配置选项 | SM 数量和 SM 重叠由分区方式决定。 |
| 是否需要应用程序更改 | 是的,但仅在内核/GPU 代码之外。 |
| 使用场景 | 具有不同工作负载类型的单个进程;需要确保关键工作有可用的 SM 资源。 |
参考文献:
- https://docs.nvidia.com/cuda/cuda-driver-api/group__CUDA__GREEN__CONTEXTS.html
kernel<<<CTA_COUNT, THREAD_COUNT>>>(ARGUMENTS)选择 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);
_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);
这是对基于硬件资源方法的分析。
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);
sm_count 个 CTA 启动,并将实际的 CTA 数量作为参数传递。bx = shared_counter % BX;by = (shared_counter / BX) % BY;bz = shared_counter / (BX * BY);下表总结了三种方法的优缺点:
集群启动控制(Cluster Launch Control)是一种结合了多种调度策略优点的新方法。下表比较了基于问题规模、基于硬件资源、自定义(原子计数器)以及集群启动控制这四种方法的特性。集群启动控制在抢占(Preemption)、负载均衡(Load balancing)、开销(Overhead)和易用性(Ease of use)四个方面均表现出色,实现了“两全其美”的效果。
核心特性:
下图通过时间线直观展示了集群启动控制的工作模式。CPU 发起两次启动(LAUNCH)操作,流式多处理器(SM)则持续处理任务,展示了动态和持续的工作分派流程,从而实现高效的负载均衡。
集群启动控制提供了一套 API 来动态管理任务。取消一个 CTA 的基本流程如下:
__shared__ 内存。__shared__ 内存屏障(barrier)同步该请求。注意:虽然可以从多个线程发起取消请求,但这在典型工作流中并不推荐,也非必需,因为取消操作本身是低延迟的。
以下 PTX 代码展示了取消 CTA 的具体实现。代码逻辑分为前序(PROLOGUE)、线程块计算(THREAD BLOCK bx COMPUTATION)和后序(EPILOGUE)三个部分。
代码关键点解析:
单线程发起(Single Arrival):通常由线程块中的单个线程(例如 threadIdx.x == 0)发起异步取消请求,以避免冗余操作。
基于事务计数的完成机制:同步的完成与否是通过事务计数来判断的。此处的 tx_count 基于结果数据结构 uint4 的大小,用于 mbarrier 的同步。
异步请求:clusterlaunchcontrol_try_cancel 是一个异步(“in flight”)请求,它可以在前一个 CTA 仍在计算时被提交,从而实现计算和控制的重叠。
取消完成:代码通过查询 clusterlaunchcontrol_query_cancel_is_canceled 的返回值来判断取消操作是否已成功完成,并据此决定是否跳出循环。
内存栅栏(Fence):__syncthreads() 不足以保证异步代理(async proxy)操作的可见性。必须使用专门的 fence_proxy_async_generic_sync_restrict 指令来确保所有线程都能观察到异步操作的结果。
双缓冲(Double-buffering):使用双缓冲(result[2] 和 phase 变量)可以避免使用 __syncthreads() 来保护结果的覆写,从而提升性能。
避免循环剥离(Loop Peeling):使用 cg::invoke_one(cg::coalesced_threads(), ...) 代替 if (threadIdx.x == 0) 可以让所有线程执行统一的指令路径,避免了因条件分支导致的线程束发散(divergence),这是一种常见的性能优化技巧。
多维 CTA 适配:在使用一维、二维或三维 CTA 时,需要相应地调整代码逻辑,例如发起请求的线程判断以及获取 CTA ID 的方式。
在集群范围内取消 CTA 的流程与单个 CTA 类似,但引入了多播(multicast)机制:
__shared__ 内存中。__shared__ 内存屏障进行同步。下图展示了一个 2x2 集群中,根 CTA (0,0) 将取消结果多播到其他 CTA 的示意图。
以下 PTX 代码展示了在集群范围内进行取消操作的实现。关键改动包括使用集群组同步 cg::cluster_group::sync()、通过 cg::cluster_group::thread_rank() == 0 选择发起线程,以及调用多播版本的取消指令 clusterlaunchcontrol_try_cancel_multicast。
代码关键点解析:
* 集群同步:在循环开始处的 cg::cluster_group::sync() 调用至关重要。它确保了在第一次迭代时集群内所有 CTA 都已准备就绪,并保护了共享数据在后续迭代中不被覆写。
以下代码片段展示了在集群情况下使用集群启动控制 API 的一个示例。
cg::cluster_group::sync() 进行同步。if (cg::cluster_group::thread_rank() == 0):由单个集群线程发起请求,每个 CTA(Cooperative Thread Array,线程块)完成请求。bx = cg::this_cluster().block_index(J):从多播的根节点获取当前 CTA 的索引。以下是在 NVIDIA B200 上使用 4GB 数组进行的计时测试,展示了集群启动控制在负载均衡方面的优势。
基准情况:当块(block)数量等于 SM(Streaming Multiprocessor)数量时:
有另一个内核并行运行(占用一个 SM):
线程块在某个 SM 上运行时间长 25%(负载不均):
下图展示了传统持久化内核在处理优先级任务时的局限性。
kernel_p。kernel_h。kernel_p 会占据整个 GPU 资源,导致高优先级内核 kernel_h 无法提前开始执行,必须等待 kernel_p 结束。下图展示了使用集群启动控制的内核如何解决优先级问题。
kernel_w。kernel_h。kernel_h 可以立即开始执行。这是因为启动控制内核 kernel_w 允许“让出”(yield)计算资源给更高优先级的任务。该幻灯片列出了一系列与 CUDA 相关的开发者会议主题,涵盖了通用 CUDA、CUDA Python、CUDA C++、开发者工具、多 GPU 编程和性能优化等领域。