高级并行总结

1. 内存优化 (Memory Optimization) - 最关键的瓶颈

GPU 的内存带宽远高于 CPU,但延迟也高。高效利用内存是性能优化的核心。

  • 最大化内存吞吐量 (Maximize Memory Throughput):
    • 合并内存访问(Coalesced Memory Access): 这是最重要的技巧。确保一个 warp (32 threads) 中的连续线程访问全局内存(Global Memory)中的连续地址。例如,threadIdx.xi 的线程访问 data[i]。非合并访问(如 data[i * stride]stride 很大)会导致大量内存事务,性能急剧下降。
    • 使用合适的 内存类型:
      • 共享内存 (Shared Memory): 位于片上(on-chip),速度接近 L1 缓存。手动管理,用于线程块(block)内线程间的通信和数据重用。常用于分块计算 (Tiling),如矩阵乘法中将子矩阵加载到共享内存。
      • 常量内存 (Constant Memory): 只读,有缓存。适用于所有线程访问相同数据的场景(如查找表、系数)。
      • 纹理内存 (Texture Memory): 针对 2D/3D 空间局部性优化,有缓存。适用于图像处理、插值等。
      • L1/L2 缓存: 现代 GPU(Compute Capability >= 3.5)有可配置的 L1/L2 缓存。了解其行为有助于优化数据访问模式。
    • 减少内存事务 (Minimize Memory Transactions): 通过合并访问和使用合适的数据类型(如 float4 代替 4 个 float)来减少内存事务次数。
  • 内存访问模式优化:
    • 避免 Bank Conflicts in Shared Memory: 共享内存被划分为多个 bank。如果一个 warp 中的多个线程同时访问同一个 bank 的不同地址,会发生 bank conflict,导致串行化访问。设计共享内存布局时,通常通过添加填充 (padding) 来错开访问模式,避免冲突。

2. 指令级优化 (Instruction-Level Optimization)

  • 减少分支发散 (Minimize Divergent Branching):

    • 在一个 warp 内,如果线程执行不同的代码路径(if-else),GPU 会串行执行所有分支,然后屏蔽(mask)不执行的线程。这称为分支发散 (Branch Divergence),会浪费计算资源。
    • 技巧: 尽量让同一个 warp 内的线程执行相同的代码路径。重构算法以减少条件判断,或使用 __syncthreads() 确保同步点前的分支收敛。
  • 使用快速 数学函数 (Fast Math Intrinsics):

    • 使用 __sinf(), __cosf(), __expf() 等内置函数代替标准库函数(sinf, cosf),它们通常更快但精度稍低。
    • 编译时使用 -use_fast_math 选项(谨慎使用,可能影响精度)。
  • 避免过度使用寄存器 (Avoid Excessive Register Usage):

    • 每个线程有有限的寄存器。如果核函数使用过多寄存器,会限制每个 SM (Streaming Multiprocessor) 上能并发的线程数(称为 寄存器瓶颈)。
    • 技巧: 使用 __launch_bounds__ 编译指示来提示编译器限制寄存器使用,以增加并发度。分析 ptxas 编译器输出或使用 nvprof/Nsight Compute 查看寄存器使用情况。

3. 计算与内存重叠 (Overlap Computation and Memory Transfer)

  • 异步内存传输 (Asynchronous Memory Transfers):

    • 使用 cudaMemcpyAsync 配合 CUDA 流(Streams)可以在 GPU 执行核函数的同时,进行主机(Host)和设备(Device)之间的数据传输。
    • 技巧: 创建多个 CUDA 流,将计算和数据传输任务分配到不同的流中,实现流水线(Pipelining)并行。
  • 重叠核函数执行 (Overlap Kernel Execution):

    • 在支持的 GPU 上(如支持 Hyper-Q 的 Kepler 及以后架构),多个流可以并发执行多个核函数,只要资源(SM)允许。

4. 线程与块组织优化 (Thread And Block Organization)

  • 选择合适的 Block Size:

    • Block size 应该是 warp size (32) 的倍数(通常是 128, 256, 512)。
    • 目标是让每个 SM 能容纳尽可能多的活跃 warp,以隐藏内存延迟(Occupancy)。使用 CUDA Occupancy Calculator 或 cudaOccupancyMaxPotentialBlockSize API 来确定最优的 block size。
    • 避免过小的 block size(无法充分利用 SM)或过大的 block size(受限于共享内存或寄存器)。
  • 最大化 Occupancy:

    • Occupancy 是指每个 SM 上活跃 warp 数占最大可能 warp 数的比例。高 occupancy 有助于隐藏延迟。
    • 通过减少每个线程的资源消耗(共享内存、寄存器)或调整 block size 来提高 occupancy。
  • 分块 (Tiling / Blocking): 将大问题分解成小块,利用共享内存进行数据重用。经典例子是分块矩阵乘法。

  • 向量化内存访问 (Vectorized Memory Access): 使用 float4, int2 等向量类型一次性加载/存储多个数据,提高内存吞吐量。

  • 避免内存依赖 (Avoid Memory Dependencies): 确保内存访问没有写后读(WAR)、读后写(RAW)、写后写(WAW)等依赖,允许编译器和硬件进行优化。

  • 使用 Warp-Level Primitives:

    • Warp Shuffle: 允许同一个 warp 内的线程直接交换数据,无需通过共享内存,速度极快。用于 __shfl_down_sync, __shfl_xor_sync 等实现高效的归约(Reduction)、扫描(Scan)操作。
    • Warp Matrix Instructions (Tensor Cores): 对于支持 Tensor Cores 的 GPU(Volta 及以后),使用 wmma API 进行混合精度(如 FP16 输入,FP32 输出)的矩阵乘加运算,性能远超传统方法。

5. 使用高级库和工具

  • 利用 CUDA 库:
    • cuBLAS: 高性能线性代数(BLAS)。
    • cuFFT: 快速傅里叶变换。
    • cuDNN: 深度神经网络原语(卷积、池化等)。
    • Thrust: C++ 模板库,提供类似 STL 的并行算法(sort, reduce, transform),简化开发。
    • 这些库经过高度优化,通常比手写核函数性能更好
  • CUDA 和其他工具结合:
    • OpenMP+CUDA:混合 CPU-GPU 并行
    • CUDA+MPI:分布式多节点 GPU 计算
    • CUDA 与深度学习框架:自定义算子开发
    • 多 GPU 编程:见 GPU 通信方式概述
    • Python 接口
      • PyCUDA:直接在 Python 中调用 CUDA 核函数。
      • Numba:通过 JIT 编译 Python 函数为 CUDA 代码,简化开发。
  • 使用性能分析工具 (Profiling Tools):
    • Nsight Compute: 详细的核函数性能分析,提供指令、内存、分支等的详细指标。
    • Nsight Systems: 系统级性能分析,可视化整个应用程序的时间线(核函数、内存传输、CPU 工作)。
    • nvprof (已逐步被 Nsight 取代): 命令行性能分析器。
    • 这些工具是识别性能瓶颈(是内存带宽限制?计算限制?分支发散?)的必备手段

内存

Bank Conflicts

1. 什么是共享内存库 (Shared Memory Banks)?

共享内存(Shared Memory)虽然对程序员来说是一个连续的内存块,但在硬件层面,它被物理地划分为多个独立的存储体(Banks)。这些 Bank 可以并行地进行访问。

  • Bank 数量: 现代 GPU (Compute Capability >= 2.0) 通常有 32 个共享内存 Bank。这个数量与一个 warp 的线程数(32)相匹配。
  • Bank 映射: 共享内存的地址按照模 32 的方式映射到 Bank。
    • 地址 addr 属于 Bank addr % 32
    • 例如:地址 0, 32, 64, 96… 属于 Bank 0;地址 1, 33, 65… 属于 Bank 1;… 地址 31, 63, 95… 属于 Bank 31。

2. 什么是 Bank Conflict?(The Problem)

Bank Conflict 发生在同一个 warp 的多个线程试图在同一个时钟周期内访问同一个 Bank 中的不同地址时。

  • 为什么是问题?

    1. 并行访问: 如果一个 warp 的 32 个线程访问的是 32 个不同的 Bank(例如,每个线程访问地址 ii 从 0 到 31),那么这 32 次访问可以在一个时钟周期内并行完成。这是最理想的情况,称为无冲突 (No Bank Conflict)
    2. 串行化访问: 如果多个线程访问同一个 Bank 的不同地址,硬件无法在同一周期内满足这些请求。这些访问请求会被串行化 (Serialized)。例如,如果有 4 个线程访问 Bank 5 的不同地址,那么这 4 次访问需要 4 个时钟周期才能完成。
    3. 性能影响: 一个 warp 的内存访问延迟由访问同一个 Bank 的线程数决定。如果最大冲突数是 N,那么访问延迟就增加 N 倍。这会严重拖慢整个 warp 的执行速度,浪费宝贵的计算资源。
  • 关键点: 访问同一个 Bank 的相同地址(例如,所有线程都读 sdata[0]不会产生冲突!这被称为广播 (Broadcast),硬件会将该值发送给所有请求的线程。冲突只发生在访问同一个 Bank 的不同地址时。


3. 典型例子 (Typical Examples)

例子 1: 矩阵转置 - 经典的 Bank Conflict 场景

#define TILE_SIZE 32
__global__ void transposeNaive(float *input, float *output) {
    __shared__ float tile[TILE_SIZE][TILE_SIZE]; // 32x32 shared memory array
 
    int x = blockIdx.x * blockDim.x + threadIdx.x; // 列索引
    int y = blockIdx.y * blockDim.y + threadIdx.y; // 行索引
 
    // 1. 从全局内存读取 (合并访问)
    tile[threadIdx.y][threadIdx.x] = input[y * N + x];
 
    __syncthreads(); // 同步
 
    // 2. 写回全局内存 (转置)
    output[x * N + y] = tile[threadIdx.x][threadIdx.y]; // 问题在这里!
}
  • 分析 Bank Conflict:
    • 假设 blockDim.x = 32, blockDim.y = 32。一个 warp 通常包含 32 个 threadIdx.x 连续、threadIdx.y 相同的线程(例如,threadIdx.y = 0threadIdx.x 从 0 到 31)。
    • 在写回步骤 output[x * N + y] = tile[threadIdx.x][threadIdx.y] 中,这些线程访问 tile[threadIdx.x][0]
    • tile 是一个二维数组。在内存中,它按行主序 (row-major) 存储:tile[0][0], tile[0][1], tile[0][2], …, tile[0][31], tile[1][0], …
    • 访问的地址偏移量:threadIdx.xi 的线程访问 &tile[i][0],其相对于 tile[0][0] 的偏移是 i * sizeof(float) * TILE_SIZE = i * 4 * 32 = i * 128 字节。
    • Bank 计算: Bank ID = (i * 128) % 32 = (i * 4) % 32
      • i=0: Bank (0*4)%32 = 0
      • i=1: Bank (1*4)%32 = 4
      • i=2: Bank (2*4)%32 = 8
      • i=3: Bank (3*4)%32 = 12
      • i=4: Bank (4*4)%32 = 16
      • i=5: Bank (5*4)%32 = 20
      • i=6: Bank (6*4)%32 = 24
      • i=7: Bank (7*4)%32 = 28
      • i=8: Bank (8*4)%32 = 0 冲突!与 i=0 同 Bank
      • i=9: Bank (9*4)%32 = 4 冲突!与 i=1 同 Bank
      • … 以此类推。
    • 结果: 每 8 个连续的 threadIdx.x (0-7, 8-15, 16-23, 24-31) 会访问 8 个不同的 Bank (0, 4, 8, 12, 16, 20, 24, 28)。但由于 i 每增加 8,Bank ID 重复一次,所以对于 i=0,8,16,24 都访问 Bank 0!
    • 冲突程度: 在一个 warp (32 threads) 内,有 4 个线程访问 Bank 0,4 个访问 Bank 4,…,4 个访问 Bank 28。这是一个 4-way bank conflict。原本可以在 1 个周期完成的访问,现在需要 4 个周期,性能下降为 1/4。

例子 2: 访问步长不为 1 的数组

__shared__ float sdata[64]; // 64 elements
// ...
// 假设一个 warp 的 32 个线程执行:
float val = sdata[threadIdx.x * 2]; // 访问 0, 2, 4, 6, ..., 62
  • 分析:
    • 线程 i 访问地址偏移 i*2*4 = i*8 字节。
    • Bank ID = (i * 8) % 32
      • i=0: Bank 0
      • i=1: Bank 8
      • i=2: Bank 16
      • i=3: Bank 24
      • i=4: Bank (4*8)%32 = 0 冲突!
      • i=5: Bank (5*8)%32 = 8 冲突!
    • 结果: i=0,4,8,12,16,20,24,28 都访问 Bank 0!这是一个 8-way bank conflict,性能极差。

4. 解决方案 (Solutions)

方案 1: 添加填充 (Padding) - 最常用

这是解决 Bank Conflict 最直接有效的方法。通过在数组的每一“行”末尾添加额外的元素(填充),改变后续行的地址偏移,从而错开 Bank 映射。

  • 应用到矩阵转置例子:

  •   #define TILE_SIZE 32
      #define SHAREDMEM_PAD 1 // 添加 1 个 float 的填充
      __global__ void transposePadded(float *input, float *output) {
          // 注意:第二维增加了填充
          __shared__ float tile[TILE_SIZE][TILE_SIZE + SHAREDMEM_PAD];
      
          int x = blockIdx.x * blockDim.x + threadIdx.x;
          int y = blockIdx.y * blockDim.y + threadIdx.y;
      
          // 1. 读取 (注意索引)
          tile[threadIdx.y][threadIdx.x] = input[y * N + x];
      
          __syncthreads();
      
          // 2. 写回 (转置)
          output[x * N + y] = tile[threadIdx.x][threadIdx.y];
      }
  • 分析:

    • 现在 tile 的“行”长度是 33float (132 字节)。
    • 访问 tile[i][0] 的偏移是 i * sizeof(float) * (TILE_SIZE + PAD) = i * 4 * 33 = i * 132 字节。
    • Bank ID = (i * 132) % 32
      • 132 % 32 = 4 (因为 32*4=128, 132-128=4)
      • 所以 Bank ID = (i * 4) % 32
    • 等等,这和之前 (i*4)%32 一样?不! 关键是 132 不能被 32 整除(132 / 32 = 4.125)。这意味着随着 i 增加,Bank ID 的序列会“漂移”。
    • 计算前几个:
      • i=0: Bank 0
      • i=1: Bank 4
      • i=2: Bank 8
      • i=3: Bank 12
      • i=4: Bank 16
      • i=5: Bank 20
      • i=6: Bank 24
      • i=7: Bank 28
      • i=8: Bank (8*132)%32 = (1056)%32 = 0 (1056 / 32 = 33 正好) 又是 0!
    • 问题: 13232 的最大公约数 (GCD) 是 4。冲突仍然存在(4-way)。
  • 正确的填充策略:

    • 目标是让 (TILE_SIZE + PAD) * sizeof(float)32 互质(GCD=1),或者至少让步长 (TILE_SIZE + PAD) * sizeof(float) 模 32 的结果能遍历所有 Bank。
    • 一个简单有效的规则是:添加一个不被 Bank 数量整除的填充。对于 32 Bank,添加 1float (4 字节) 的填充,使得“行”大小为 33*4=132 字节。虽然 132 % 32 = 4,但 432 的 GCD 是 4,仍然有 4-way 冲突。
    • 最佳实践: 添加 1float 的填充 (#define SHAREDMEM_PAD 1) 是广泛使用的经验法则,对于 TILE_SIZE=32 这种常见情况,它能将 4-way 冲突减少到可接受的程度,或者在某些访问模式下完全消除。更精确的分析需要具体计算。有时添加 57float 可能更优。

方案 2: 改变数据布局或访问模式

  • 转置存储: 在共享内存中,一开始就将数据按转置后的顺序存储。但这通常不现实,因为读取全局内存时希望是合并访问。
  • 使用一维数组索引: 有时通过巧妙的索引计算可以避免冲突,但这取决于具体算法。

方案 3: 利用广播 (Broadcast)

  • 如果多个线程需要读取同一个值,确保它们访问同一个地址。硬件会自动广播,无冲突。
  • 例如,在归约操作中,当读取 sdata[0] 时,所有线程都读它,这是广播,无冲突。

方案 4: 使用更小的数据类型

  • 如果使用 float (4 bytes) 会导致冲突,考虑是否可以使用 short (2 bytes) 或 char (1 byte)。这会改变 Bank 映射。
  • 例如,short sdata[64],访问 sdata[threadIdx.x * 2],偏移 i*2*2=i*4 字节,Bank ID = (i*4)%32。如果 i 从 0 到 31,这会导致 8-way 冲突(i=0,8,16,24 访问 Bank 0)。但如果访问 sdata[threadIdx.x],则无冲突。需要根据具体情况权衡。

5. 如何检测 Bank Conflict?

  • Nsight Compute: 能提供详细的性能指标,包括 shared_load_transactions_per_requestshared_store_transactions_per_request。如果这个值大于 1,说明存在 Bank Conflict。它还能可视化内存访问模式。
  • nvprof: 较旧的工具,也能提供类似的共享内存事务统计。
  • 手动计算: 像上面例子一样,仔细分析你的共享内存访问模式和 Bank 映射。

总结

  • Bank Conflict 是由于 warp 内多个线程在同周期访问同一 Bank 的不同地址导致的访问串行化。
  • 根本原因 是共享内存的地址到 Bank 的映射方式(addr % num_banks)和线程的访问模式不匹配。
  • 典型场景:矩阵转置、访问有固定步长的数组。
  • 主要解决方案添加填充 (Padding) 是最常用和有效的方法。通过增加数组维度的大小,改变地址偏移,从而错开 Bank 映射。
  • 最佳实践:在编写涉及二维共享内存数组的代码时,特别是像 TILE_SIZE=32 这种与 Bank 数相同的尺寸,务必考虑 Bank Conflict 问题。使用 Nsight Compute 等工具进行分析,并通过添加填充来优化。记住,消除 Bank Conflict 可以带来数倍的性能提升。

合并内存访问

1. 什么是合并内存访问?(What Is Coalesced Memory Access?)

简单来说,合并内存访问是指一个 warp (32 个连续的线程) 在访问全局内存(Global Memory)时,它们的内存请求能够被高效地组合成尽可能少的、连续的内存事务(memory transactions)发送到内存控制器。

  • 理想情况 (Perfect Coalescing): 一个 warp 的 32 个线程访问 32 个连续的 4 字节(float)数据,总共 128 字节。如果这 128 字节正好对齐在 128 字节的边界上,那么 GPU 的内存控制器可以用一次 128 字节的内存事务完成所有数据的加载或存储。
  • 非合并访问 (Uncoalesced Access): 如果这 32 个线程访问的地址非常分散(例如,每个线程访问一个相隔很远的 float),那么内存控制器可能需要发起32 次独立的 4 字节内存事务。这比一次 128 字节的事务慢得多,因为每次事务都有固定的开销(地址设置、命令发送等)。

核心目标:最大化每次内存事务传输的有效数据量,最小化内存事务的总次数。


2. 原理 (The Principle) - 内存事务与缓存行

要理解合并,需要了解 GPU 内存访问的底层机制:

  1. Warp 是基本单位: GPU 调度和执行的基本单位是 warp (32 threads)。当一个 warp 中的线程执行一条内存访问指令(如 data[i] = ...)时,硬件会检查这个 warp 所有 32 个线程的内存请求。
  2. 内存事务 (Memory Transaction):
    • GPU 的全局内存通过内存控制器访问。
    • 内存控制器处理内存请求的最小单位是内存事务
    • 事务大小 (Transaction Size): 这取决于 GPU 的架构和内存总线宽度。常见的大小是 32 字节 (L1 缓存行大小)128 字节 (L2 缓存行大小或内存总线宽度)。现代 GPU 通常以 128 字节为单位进行高效访问。
  3. 合并过程:
    • 硬件分析一个 warp 的 32 个内存请求的地址。
    • 如果这些地址是连续的高度聚集的,并且对齐在一个事务大小的边界上,硬件就能将这些请求合并 (Coalesce) 成一个或少数几个大的内存事务。
    • 如果地址分散,无法合并,硬件就不得不发起多个小的、独立的事务。
  4. 对齐 (Alignment):
    • 为了实现最佳合并,内存访问的起始地址应该对齐到事务大小的边界(例如,128 字节对齐)。
    • 即使地址连续,如果起始地址没有对齐(比如从地址 16 开始访问 128 字节),也可能需要两次事务(一次覆盖 0-127,一次覆盖 128-255,但只取其中一部分)。

关键点总结:

  • 连续性: 同一个 warp 内,连续的线程(threadIdx.x 相邻)应访问连续的内存地址。
  • 对齐: 访问的起始地址最好对齐到较大的事务边界(如 128 字节)。
  • 粒度: 访问的数据类型大小(float=4B, int=4B, double=8B)也会影响合并效率。访问 float 比访问 char 更容易合并。

3. 典型的算子例子 (Typical Operator Examples)

例子 1: 向量加法 (Vector Addition) - 完美合并的典范

__global__ void vectorAdd(float *A, float *B, float *C, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        C[idx] = A[idx] + B[idx]; // 所有线程访问 A[idx], B[idx], C[idx]
    }
}
  • 分析:
    • 假设 blockDim.x = 32,那么一个 warp 的 32 个线程的 idx 分别是 k, k+1, k+2, ..., k+31
    • 它们分别访问 A[k], A[k+1], …, A[k+31] —— 连续的 32 个 float (128 字节)。
    • 只要 k 是 32 的倍数(即 idx 从 warp 边界开始),并且数组 A, B, C 的基地址是 128 字节对齐的,那么对 A, B, C 的每次访问都能被完美合并为一次 128 字节的内存事务。
  • 这是合并访问最理想、最高效的场景。

例子 2: 矩阵转置 (Matrix Transpose) - 经典的非合并问题

// 假设矩阵 M 是 row-major 存储: M[row][col] = M[row * width + col]
__global__ void matrixTransposeNaive(float *M, float *MT, int width, int height) {
    int x = blockIdx.x * blockDim.x + threadIdx.x; // 列索引
    int y = blockIdx.y * blockDim.y + threadIdx.y; // 行索引
    if (x < width && y < height) {
        MT[x * height + y] = M[y * width + x]; // MT[col][row] = M[row][col]
    }
}
  • 分析 (问题所在):
    • 假设一个 block 是 32x32。一个 warp 通常包含 32 个连续的 threadIdx.x,但 threadIdx.y 相同。
    • M 上的读取 (M[y * width + x]): 对于一个 warp,y 固定,x 从 0 到 31。所以访问的是 M[y*width+0], M[y*width+1], …, M[y*width+31] —— 连续的 32 个 float对 M 的读取是合并的!
    • MT 上的写入 (MT[x * height + y]): x 从 0 到 31,y 固定。访问的是 MT[0*height+y], MT[1*height+y], …, MT[31*height+y]。如果 height 很大(比如 1024),那么这些地址 y, height+y, 2*height+y, … 31*height+y 之间相隔 height * sizeof(float) 字节,非常分散
    • 结果:对 MT 的写入是严重非合并的。一个 warp 的 32 次写入可能需要 32 次独立的内存事务,性能极差。
  • 解决方案 (利用共享内存):
    • 使用共享内存作为“垫脚石”。
    • 一个 block 先将 M 的一个 32x32 子块合并地读取到共享内存 tile[32][32] 中。
    • 然后,线程协作,将 tile 中的数据转置后,再合并地写入到全局内存 MT 的对应位置。
    • 这样,全局内存的读写都是合并的,性能大幅提升。这是“分块 (Tiling)”技术的经典应用。

例子 3: 矩阵乘法 (Matrix Multiplication) - 分块优化的核心

// GEMM: C = A * B
__global__ void matrixMulNaive(float *A, float *B, float *C, int M, int N, int K) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    if (row < M && col < N) {
        float sum = 0.0f;
        for (int k = 0; k < K; ++k) {
            sum += A[row * K + k] * B[k * N + col];
        }
        C[row * N + col] = sum;
    }
}
  • 分析 (问题):
    • 计算 C[row][col] 需要 A 的第 row 行和 B 的第 col 列。
    • A 的访问 (A[row * K + k]): k 循环时,访问 A[row][0], A[row][1], …, A[row][K-1] —— 连续的。对于计算 C 的同一行的线程(row 相同),它们对 A 的访问是合并的
    • B 的访问 (B[k * N + col]): k 循环时,访问 B[0][col], B[1][col], …, B[K-1][col] —— 这是 B 的第 col 列。在 row-major 存储下,列元素在内存中是不连续的,间隔 N * sizeof(float)。这是一个严重的非合并访问模式,性能瓶颈。
  • 解决方案 (分块 + 共享内存):
    • 将大矩阵分块(例如 32x32)。
    • 使用共享内存 Asub[TILE_SIZE][TILE_SIZE]Bsub[TILE_SIZE][TILE_SIZE]
    • 每个 block 负责计算 C 的一个 TILE_SIZE x TILE_SIZE 子块。
    • 迭代过程中,将 A 的一个 TILE_SIZE x TILE_SIZE 行块合并地加载Asub
    • B 的一个 TILE_SIZE x TILE_SIZE 列块合并地加载Bsub (注意:虽然 B 的列在全局内存非连续,但我们可以将这个列块视为一个 TILE_SIZE x TILE_SIZE 的小矩阵,按行加载,这样加载是合并的)。
    • 在共享内存 AsubBsub 之间进行小规模矩阵乘法。
    • 最后将结果合并地写回 C
    • 这样,全局内存的读写都变成了合并访问,且数据在共享内存中被重用,极大提升了性能。这是 cuBLAS 等库实现 GEMM 的基础。

例子 4: 稀疏矩阵向量乘 (SpMV) - 合并的挑战

// CSR (Compressed Sparse Row) 格式
__global__ void spmv_csr(float *values, int *col_indices, int *row_ptr, float *x, float *y, int num_rows) {
    int row = blockIdx.x * blockDim.x + threadIdx.x;
    if (row < num_rows) {
        float sum = 0.0f;
        int start = row_ptr[row];
        int end = row_ptr[row + 1];
        for (int i = start; i < end; i++) {
            sum += values[i] * x[col_indices[i]]; // 访问 x 的随机位置
        }
        y[row] = sum;
    }
}
  • 分析:
    • y[row] 的写入是合并的(row 连续)。
    • values[i]col_indices[i] 的访问在 i 循环内是连续的(如果按行处理)。
    • 关键问题: x[col_indices[i]] 的访问是完全随机的,取决于稀疏矩阵的非零元分布。这导致对向量 x 的访问是高度非合并的。
  • 挑战与对策:
    • SpMV 是典型的“内存带宽受限”且“访问模式不规则”的问题,很难实现完美的合并。
    • 优化策略包括:使用纹理内存(有缓存)、对稀疏矩阵重新排序以增加局部性、使用更高效的稀疏格式(如 ELL, HYB)、或采用向量化处理多行等。但通常无法达到像向量加法那样的合并效率。

总结

  • 原理: 合并内存访问通过让一个 warp 的 32 个线程访问连续的内存地址,使得硬件能将 32 次小请求合并成 1-2 次大事务,极大提高内存带宽利用率。
  • 关键: 连续性 (warp 内线程访问连续地址) 和 对齐 (起始地址对齐)。
  • 典型应用:
    • 完美合并: 向量运算(加、减、点乘、缩放)、图像像素处理(按行扫描)。
    • 需要优化的非合并: 矩阵转置(行列互换)、矩阵乘法(访问列)、稀疏计算(随机访问)。
    • 优化手段: 分块 (Tiling)共享内存 (Shared Memory) 是解决非合并问题的核心武器,通过将全局内存的非合并访问转化为共享内存的合并访问或利用数据重用。

记住:在编写 CUDA 核函数时,时刻思考你的内存访问模式是否对 warp 友好。检查 threadIdx.x (或 threadIdx.y) 的变化是否对应着内存地址的连续变化。这是写出高性能 CUDA 代码的第一步。

内存类型

CUDA 的内存层次结构从靠近计算单元(线程)到远离,可分为寄存器、共享内存、本地内存、全局内存、常量内存、纹理 / 表面内存等,不同层次的内存具有不同的访问范围、生命周期、性能和操作方式。以下是各层次的寻址方式、申请(allocate)、访问与存储方法的详细说明:

一、寄存器(Registers)

  • 特点
    • 线程私有(每个线程独立拥有),访问速度最快(纳秒级),容量最小(每个线程约 255 个 32 位寄存器,具体取决于 GPU 架构)。
  • 申请(Allocate)
    • 无需显式申请,由编译器自动分配。当线程定义局部变量(且未被编译器判定为需要放入本地内存时),变量会被分配到寄存器。
__global__ void kernel() {
  int a = 0;         // 自动分配到寄存器
  float b = 3.14f;   // 自动分配到寄存器
}
  • 访问与存储
    • 访问:通过变量名直接访问(编译器优化寻址,无显式地址操作)。
    • 存储:直接对变量赋值(数据存储在寄存器中,线程执行期间有效)。
  • 寻址方式
    • 编译器管理的线程私有地址空间,每个线程的寄存器独立,无法被其他线程访问。

二、共享内存(Shared Memory)

  • 特点: 线程块(Block)私有(块内所有线程共享),访问速度接近寄存器(比全局内存快 10-100 倍),容量有限(每个块通常最大为 48KB-128KB,取决于 GPU 架构和配置)。支持用户显式控制,是线程间通信和数据复用的核心。

  • 申请(Allocate)

    • 在核函数中通过 __shared__ 关键字显式声明,分为静态分配动态分配
  • 静态分配:编译时确定大小

__global__ void kernel() {
	__shared__ float s_data[256];  // 静态分配256个float(1KB)
}
  • 动态分配:运行时指定大小(需用 extern 关键字,启动核函数时传入共享内存大小)
__global__ void kernel() {
  extern __shared__ float s_data[];  // 动态分配,大小在核函数启动时指定
}
 
// 主机端启动核函数:第三个参数指定共享内存大小(字节)
kernel<<<gridDim, blockDim, 1024>>();  // 动态分配1024字节共享内存
  • 访问与存储
    • 访问:通过数组索引访问(如 s_data[threadIdx.x]),块内所有线程可读写。
    • 存储:直接对数组元素赋值(如 s_data[i] = 1.0f)。
    • 注意:线程间访问共享内存需同步(用 __syncthreads()),避免数据竞争(如一个线程写入后,其他线程再读取)。
  • 寻址方式
    • 块内局部地址空间:每个线程块有独立的共享内存,地址范围为块内声明的数组索引(如 0~255 for s_data[256]),其他块无法访问。

三、本地内存(Local Memory)

  • 特点: 线程私有,语义上是线程的 “本地存储”,但物理上位于全局内存(因此访问速度慢,与全局内存相当)。当寄存器不足(如变量占用过多寄存器)或变量无法被编译器优化(如动态长度数组、大型数组)时,编译器会自动将变量分配到本地内存。
  • 申请(Allocate)
    • 无需显式申请,由编译器自动分配(当寄存器不足时)。例如:
__global__ void kernel() {
  float big_array[1024];  // 数组过大,寄存器存不下,自动分配到本地内存
}
  • 访问与存储
    • 访问:通过变量名或数组索引访问(与寄存器变量语法一致)。
    • 存储:直接赋值(如 big_array[i] = 2.0f),但实际操作的是全局内存中的线程私有区域。
  • 寻址方式
    • 线程私有地址空间(映射到全局内存),地址由编译器生成,仅当前线程可访问。

四、全局内存(Global Memory)

  • 特点: 设备级内存(所有线程、所有块可访问),容量最大(GB 级),访问速度较慢(数百纳秒),是主机与设备间数据传输的主要区域。生命周期与设备一致(需显式释放)。
  • 申请与释放(Allocate/Free)
    • 主机端通过 CUDA Runtime API 显式申请和释放:
// 申请全局内存(设备端)
float* d_data;
cudaMalloc(&d_data, size_in_bytes);  // 成功返回cudaSuccess,失败返回错误码
 
// 释放全局内存
cudaFree(d_data);
  • 访问与存储

  • 主机端与设备端的数据传输:通过 cudaMemcpy 实现(方向:主机→设备、设备→主机、设备→设备)

float* h_data = (float*)malloc(size_in_bytes);  // 主机内存
// 主机→设备
cudaMemcpy(d_data, h_data, size_in_bytes, cudaMemcpyHostToDevice);
  • 设备端(核函数)访问:通过指针直接访问(读写均可)
__global__ void kernel(float* d_data) {
  int idx = threadIdx.x + blockIdx.x * blockDim.x;
  d_data[idx] = 1.0f;  // 写入全局内存
  float val = d_data[idx];  // 读取全局内存
}
  • 寻址方式
    • 全局地址空间:所有线程可见,地址是设备级的全局指针(如 d_data 指向的地址),需注意内存对齐(如 64 字节对齐可提升访问效率)和合并访问(连续线程访问连续地址,最大化带宽)。

五、常量内存(Constant Memory)

  • 特点: 设备级只读内存,容量小(通常 64KB),配有专用缓存(常量缓存),适合存储所有线程都需要访问的常量数据(如参数、系数)。读取时若多个线程访问同一地址,效率极高(广播机制)。典型使用场景有查找表、算法系数、数学常数。
  • 申请与初始化
    • 全局作用域(核函数外)用 __constant__ 声明常量变量(设备端可见)。
    • 主机端通过 cudaMemcpyToSymbol 复制数据到常量内存(不可在核函数中修改)。
// 声明常量内存变量(设备端)
__constant__ float c_params[256];  // 64KB(256*4字节)
 
// 主机端初始化
float h_params[256] = {1.0f, 2.0f, …};  // 主机数据
cudaMemcpyToSymbol(c_params, h_params, 256 * sizeof(float));  // 主机→常量内存
  • 访问与存储
    • 访问:核函数中通过变量名直接访问(只读)
    • 存储:仅能在主机端通过 cudaMemcpyToSymbol 写入,核函数中不可修改。
  • 寻址方式
    • 全局只读地址空间:所有线程可见,地址由 __constant__ 变量名隐式指定,通过索引访问(如 c_params[i])。

六、纹理内存(Texture Memory)与表面内存(Surface Memory)

  • 特点: 设备级只读(纹理)或读写(表面)内存,配有专用缓存,针对2D/3D 空间局部性优化(如图像、网格数据),支持地址越界处理(如 clamping)和插值(纹理内存)。容量取决于全局内存(纹理 / 表面只是全局内存的 “视图”)。常用在图像处理、插值、信号处理、科学计算。
  • 申请与绑定
    • 先申请全局内存(作为纹理 / 表面的底层存储)。
    • 创建纹理引用(Texture Reference)或纹理对象(Texture Object,推荐),绑定到底层全局内存。
// 1. 申请全局内存(底层存储)
float* d_tex_data;
cudaMalloc(&d_tex_data, width * height * sizeof(float));
 
// 2. 创建纹理对象(现代方式,支持动态配置)
cudaResourceDesc resDesc;
memset(&resDesc, 0, sizeof(resDesc));
resDesc.resType = cudaResourceTypeLinear;  // 1D线性内存
resDesc.res.linear.devPtr = d_tex_data;    // 绑定全局内存
resDesc.res.linear.desc.f = cudaChannelFormatKindFloat;
resDesc.res.linear.desc.x = 32;  // 32位float
 
cudaTextureDesc texDesc;
memset(&texDesc, 0, sizeof(texDesc));
texDesc.addressMode[0] = cudaAddressModeClamp;  // 越界处理:clamp
 
cudaTextureObject_t texObj;
cudaCreateTextureObject(&texObj, &resDesc, &texDesc, nullptr);  // 创建纹理对象
  • 访问与存储
    • 纹理内存(只读):核函数中通过 tex1D/tex2D 等函数访问(根据维度),参数为纹理对象和坐标。
    • 表面内存(读写):类似纹理,但支持写入,通过 surf1Dwrite/surf2Dread 等函数操作。
  • 寻址方式
    • 基于坐标的空间寻址:通过纹理坐标(如 1D 的 x、2D 的 (x,y))访问,而非直接指针。支持整数或浮点坐标,缓存会自动优化空间局部性访问。

总结:各内存层次的核心操作对比

内存层次申请方式访问方式存储方式寻址特点
寄存器编译器自动分配变量名直接访问直接赋值线程私有,编译器管理地址
共享内存核函数中 __shared__ 声明(静态 / 动态)数组索引访问直接赋值,需 __syncthreads() 同步线程块私有,块内局部索引
本地内存编译器自动分配(寄存器不足时)变量名 / 数组索引访问直接赋值线程私有,映射到全局内存
全局内存主机端 cudaMalloc指针访问(核函数)+ cudaMemcpy(主机)核函数中直接赋值,主机端 cudaMemcpy设备级全局地址,指针寻址
常量内存全局 __constant__ 声明 + cudaMemcpyToSymbol变量名 + 索引访问(只读)主机端 cudaMemcpyToSymbol全局只读地址,索引寻址
纹理内存全局内存 + 绑定纹理对象tex1D/tex2D 等函数(坐标)主机端 cudaMemcpy 到底层全局内存空间坐标寻址,缓存优化

掌握各内存层次的操作方式是 CUDA 性能优化的核心:寄存器和共享内存用于高频访问数据,全局内存用于大规模数据存储,常量 / 纹理内存用于优化特定访问模式(如只读、空间局部性)。

指令优化

数学函数

__sinf(), __cosf(), __expf() 等函数是 CUDA 内置函数 (Intrinsics)设备函数 (Device Functions)。它们的实现是一个硬件与微码 (microcode) 协同的复杂过程,可以概括为以下几个层次:


1. 硬件专用单元 (Dedicated Hardware Units)

现代 GPU (尤其是 NVIDIA 的) 并非只有简单的 CUDA Core (通常指 FP32/FP64 ALU)。它们还包含专门用于处理超越函数(Transcendental Functions)的特殊功能单元 (Special Function Units - SFUs)

  • SFUs 的作用: 这些是物理上独立的硬件电路,专门设计用来高效计算 sin, cos, log, exp, rsqrt (倒数平方根) 等函数。
  • 优势: SFUs 可以在一个或几个时钟周期内完成一个超越函数的计算,远比在通用 CUDA Core 上用软件算法(如泰勒级数、CORDIC)迭代计算要快得多。
  • 指令: 编译器会将 __sinf(x) 这样的调用编译成一条特定的 PTX (Parallel Thread Execution) 指令,例如 sin.approx.f32sin.ftz.approx.f32。这条 PTX 指令最终会被 GPU 的指令解码器识别,并直接调度到 SFU 上执行

2. 微码 (Microcode) 与查找表 (Look-Up Tables - LUTs)

SFUs 本身也不是用纯组合逻辑电路直接计算 sin(x) 的。它们内部通常采用一种基于查找表和多项式逼近的混合策略,这可以看作是一种“微码”或“固件”:

  • 输入归一化 (Input Reduction):

    1. 首先,输入 x 需要被归一化到一个很小的范围(例如 [0, π/4])。sincos 是周期函数,exp 可以分解为 2^exponent * exp(fraction)
    2. 这个归一化步骤通常涉及一些算术运算(加、减、乘、除),可能在通用 CUDA Core 或 SFU 内部的辅助电路完成。
  • 核心计算 (Core Approximation):

    1. 查找表 (LUT): 归一化后的输入 x' 被用作索引,去查询一个预先计算好的、存储在 SFU 内部高速 SRAM 中的查找表。这个表存储了 sin(x') 在关键点上的值或其多项式系数。
    2. 多项式逼近 (Polynomial Approximation): 利用查表得到的值或系数,SFU 使用一个低阶多项式(如二次或三次多项式)来逼近 sin(x') 的精确值。这个多项式的计算(如 a*x'^2 + b*x' + c)就是在 SFU 内部的 ALU 上快速完成的。
    3. 常用算法: 常用的逼近算法包括 Minimax Polynomials (在给定区间内最小化最大误差) 或 Rational Approximations (有理函数)。
  • 结果重构 (Result Reconstruction):

    1. 根据归一化时的处理(如象限判断、周期偏移),将核心计算得到的 sin(x') 值进行符号调整、相位偏移等操作,得到最终的 sin(x)
    2. 这个步骤也涉及一些算术运算。

3. 编译器的角色 (The Compiler’s Role)

编译器(nvcc)是连接高级代码和硬件的关键:

  1. 识别: 编译器识别到 __sinf(x) 这样的内置函数调用。
  2. 生成 PTX: 编译器将其翻译成一条或几条特定的 PTX 指令。例如:
    • sin.approx.ftz.f32 %f1, %f2; // 计算 __sinf(%f2),结果存入 %f1approx 表示近似,ftz 表示 Flush-To-Zero。
  3. PTX 到 SASS: PTX 是虚拟汇编。ptxas (PTX 汇编器) 将 PTX 指令进一步编译成 GPU 的真实机器码(SASS - Streaming ASSembler)。
  4. 调度: SASS 指令包含了操作码(Opcode),明确指示 GPU 的指令解码器将这个操作分派给 SFU 而不是通用的 CUDA Core (FP32/FP64 ALU)。

4. 与标准库函数 (sinf) 的区别

  • sinf(x) (来自 <math.h>): 这是一个更精确的库函数。它的实现通常在通用 CUDA Core 上运行一个更复杂的软件算法(可能使用更高阶的多项式、更多迭代),以达到更高的精度。它直接使用 SFU 的快速近似指令,或者即使使用,也会进行额外的精度修正。因此,它比 __sinf() 慢得多
  • __sinf(x): 这是直接映射到 SFU 快速近似指令的内置函数。它牺牲了一些精度来换取极高的速度。这就是为什么 -use_fast_math 选项会用 __sinf 替换 sinf

5. 总结:实现流程

  1. 代码: 在 CUDA C++ 代码中调用 __sinf(x)
  2. 编译: nvcc 将其编译成 PTX 指令 sin.approx.f32
  3. 汇编: ptxas 将 PTX 编译成 SASS 机器码,其中包含一个指向 SFU 的操作码。
  4. 执行:
    • GPU 的 warp 调度器将包含 sin.approx.f32 指令的 warp 分派给一个 SM。
    • SM 的指令解码器识别该指令需要 SFU。
    • SFU 接收指令和操作数 x
    • SFU 内部的微码/电路执行:
      • 输入归一化。
      • 查找表 (LUT) 访问。
      • 低阶多项式计算。
      • 结果重构。
    • SFU 将计算结果写回寄存器。
  5. 完成: warp 继续执行后续指令。

总结:__sinf() 等函数的实现不是简单地由编译器分解成基础的乘加指令在通用 CUDA Core 上发射。而是由编译器生成一条专用指令,这条指令被 GPU 硬件直接调度到专门的 SFU (Special Function Unit) 上执行。SFU 内部利用查找表 (LUT) 和低阶多项式逼近的“微码”策略,在极短时间内完成计算,从而实现了速度和精度的平衡。这是一个硬件加速的典型例子。

分块计算

  1. 分块计算 (Tiling / Blocking):

    • 场景: 大规模矩阵运算(如 GEMM - 矩阵乘法)、卷积神经网络 (CNN) 的卷积层。
    • 例子: 分块矩阵乘法
      • 将大矩阵 A, B 分解成 TILE_SIZE x TILE_SIZE 的小块。
      • 每个线程块将 A 的一个行块和 B 的一个列块从全局内存合并地加载到共享内存 (Asub, Bsub)。
      • 线程块内的线程协作,利用 AsubBsub 计算 C 的一个结果块。
      • 由于 AsubBsub 在共享内存中,可以被反复读取,避免了多次访问慢速的全局内存。
      • 关键: 利用共享内存实现数据重用 (Data Reuse)
  2. 线程块内通信与协作:

    • 场景: 归约 (Reduction)、扫描 (Scan / Prefix Sum)、查找最大值/最小值。
    • 例子: 并行归约求和
      • 将一个大数组的 blockDim 个元素加载到共享内存 sdata[]
      • 使用树形结构(或蝶形结构)进行归约:sdata[tid] += sdata[tid + stride],每次迭代 stride 减半。
      • 每次迭代后使用 __syncthreads() 同步,确保所有线程完成读写。
      • 最终,sdata[0] 包含块内所有元素的和。
      • 关键: 利用共享内存实现线程间通信,避免了对全局内存的频繁原子操作。
  3. 缓存小数据集以减少全局内存访问:

    • 场景: 需要重复访问一小段数据的算法。
    • 例子: 直方图计算
      • 每个线程块负责处理输入数据的一部分。
      • 块内的每个线程都有一个局部的直方图计数器(在寄存器或共享内存中)。
      • 处理完后,将块内所有线程的计数器结果归约到共享内存的一个数组中。
      • 最后,由一个线程将共享内存中的结果原子地加到全局直方图数组中。
      • 关键: 将频繁的原子操作从全局内存转移到共享内存(块内归约),只在最后进行一次或少数几次全局原子操作。

Warp Shuffle

CUDA 中的线程级并行原语——Warp Shuffle(也称为 Shuffle Instructions 或 Warp-Level Primitives)。它是在 Kepler 架构(Compute Capability 3.0)引入的,极大地提升了 warp 内线程间通信的效率。


1. 什么是 Warp Shuffle?(What is Warp Shuffle?)

Warp Shuffle 是一组硬件指令,允许同一个 warp(32 个线程)内的线程直接交换数据,而无需通过共享内存(Shared Memory)或全局内存。

  • 核心思想: 在一个 warp 内部,线程 i 可以直接从线程 j 的寄存器中“拿”数据。
  • 关键优势: 极低的延迟和高带宽。因为数据交换发生在寄存器文件内部或通过专用的 warp shuffle 网络,避免了访问共享内存的开销(虽然共享内存也很快,但仍有延迟和 bank conflict 风险)。

2. 原理 (The Principle)

  • 硬件支持: GPU 的 SM 内部有专门的硬件电路(或微码)来支持 warp shuffle 操作。它不依赖于通用的内存加载/存储单元。
  • 操作方式: Shuffle 指令通常指定一个 source thread ID(源线程在 warp 中的索引,0-31)和一个 value(要发送的值)。执行后,每个线程会从指定的源线程那里接收数据。
  • 同步性: Shuffle 操作是隐式同步的。所有参与 shuffle 的线程必须同时执行相同的 shuffle 指令。如果一个 warp 内的线程执行路径发散(例如,由于 if 语句),那么在发散的路径上执行 shuffle 指令会导致未定义行为。因此,在使用 shuffle 前,通常需要确保 warp 内没有分支发散,或者使用掩码(mask)参数来控制。
  • 常用指令 (以 __shfl_*_sync 系列为例,_sync 版本是推荐使用的,更安全):
    • __shfl_sync(mask, value, srcLane, width):
      • mask: 一个 32 位掩码,指定参与此 shuffle 操作的线程。通常用 0xffffffff 表示所有 32 个线程都参与。
      • value: 当前线程要“发送”或“参与”shuffle 的值。
      • srcLane: 目标线程想要从中获取数据的源线程的 lane ID0-31)。
      • width: 可选参数,用于子 warp shuffle(见下文)。
      • 返回值: 源线程 srcLanevalue 值。
    • __shfl_up_sync(mask, value, delta, width): 从 laneID - delta 的线程获取数据。
    • __shfl_down_sync(mask, value, delta, width): 从 laneID + delta 的线程获取数据。
    • __shfl_xor_sync(mask, value, laneMask, width): 从 laneID XOR laneMask 的线程获取数据。这在实现 butterfly 网络(如归约)时非常高效。

3. 典型使用例子 (Typical Examples)

例子 1: Warp 内归约 (Warp-Level Reduction)

这是 shuffle 最经典的应用。用于在一个 warp 内快速求和、求最大值等。

__device__ float warpReduceSum(float val) {
    for (int offset = 16; offset > 0; offset /= 2) {
        // 使用 __shfl_down_sync: 当前线程从 (laneID + offset) 的线程获取值
        // 并将该值加到自己的 val 上
        val += __shfl_down_sync(0xffffffff, val, offset);
    }
    return val; // warp 中 lane 0 的线程现在持有整个 warp 的和
}
 
__global__ void reduceWarpShfl(float *g_idata, float *g_odata, int n) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    int wid = tid / 32; // warp ID
    int lane = tid % 32; // lane ID within warp
 
    float sum = 0.0f;
    // 假设每个线程处理多个元素
    for (int i = tid; i < n; i += blockDim.x * gridDim.x) {
        sum += g_idata[i];
    }
 
    // 在 warp 内进行归约
    sum = warpReduceSum(sum);
 
    // 将每个 warp 的结果写回全局内存(由 lane 0 的线程写)
    if (lane == 0) {
        g_odata[wid] = sum;
    }
}
  • 分析:
    • 初始: 每个线程有自己的 sum 值。
    • offset=16: lane ilane i+16 获取值并相加。lane 0-15 得到 lane 16-31 的值,lane 16-31 的值不变(因为 i+16 >=32__shfl_down 会返回自己的值或未定义,通常实现为返回自身)。
    • offset=8: lane ilane i+8 获取值。现在 lane 0-7lane 8-15 的值包含了 lane 16-31 的贡献。
    • offset=4, 2, 1: 继续类似操作。
    • 最终: lane 0sum 包含了整个 warp 32 个线程的总和。
  • 优势: 相比使用共享内存归约,避免了 __syncthreads() 同步开销和潜在的 bank conflict。代码更简洁,性能通常更高。

例子 2: 广播 (Broadcast)

将一个 warp 内某个特定线程的值广播给所有其他线程。

__device__ void warpBroadcast(float &val, int srcLane) {
    val = __shfl_sync(0xffffffff, val, srcLane);
}
 
__global__ void findMaxAndBroadcast(float *data, float *result, int n) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    int lane = tid % 32;
 
    float localMax = -INFINITY_F;
    // 每个线程找到自己负责部分的最大值
    for (int i = tid; i < n; i += blockDim.x * gridDim.x) {
        localMax = fmaxf(localMax, data[i]);
    }
 
    // 在 warp 内归约找到最大值 (可以使用上面的 warpReduceMax 或类似方法)
    float warpMax = warpReduceMax(localMax); // 假设有类似实现
 
    // 将 lane 0 得到的 warpMax 广播给 warp 内所有线程
    warpBroadcast(warpMax, 0);
 
    // 现在 warp 内所有线程的 warpMax 都是相同的
    // ... 后续处理,比如写回或进一步归约
}
  • 分析: __shfl_sync(mask, val, srcLane) 让所有线程从 srcLane 线程获取 val 的值。这里 srcLane=0,所以所有线程都拿到 lane 0warpMax

例子 3: 循环移位 (Circular Shift)

实现 warp 内数据的循环移位。

__device__ float warpCircularShiftDown(float val, int delta) {
    int lane = threadIdx.x % 32;
    int shiftedLane = (lane + delta) % 32; // 循环
    return __shfl_sync(0xffffffff, val, shiftedLane);
}
 
__global__ void circularShiftExample(float *input, float *output, int n, int shift) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    if (tid >= n) return;
 
    float val = input[tid];
    float shiftedVal = warpCircularShiftDown(val, shift);
    output[tid] = shiftedVal;
}
  • 分析: lane ilane (i + shift) % 32 获取值,实现了向下(向高 lane ID)的循环移位。

例子 4: 实现 Warp Scan (前缀和)

__device__ float warpScanInclusive(float val) {
    // 使用 __shfl_up_sync 实现上行归约(但方向相反)
    // Butterfly 网络风格
    for (int offset = 1; offset < 32; offset *= 2) {
        int upLane = (threadIdx.x % 32) - offset;
        float temp = __shfl_up_sync(0xffffffff, val, offset);
        if (upLane >= 0) {
            val += temp;
        }
        // 注意:这里没有处理负索引的广播问题,简化版
        // 更健壮的实现需要处理边界
    }
    return val; // 当前线程的 val 是 [0, laneID] 的前缀和
}
  • 分析: 这是一个简化的包含性前缀和(inclusive scan)。每个线程累加来自“上方”(lane ID 更小)特定距离的线程的值。实际实现需要更精细地处理边界(如 upLane < 0 时,__shfl_up 可能返回 0 或自身,取决于实现)。

4. 与共享内存归约的对比

特性Warp Shuffle 归约共享内存归约
速度更快 (寄存器级交换)快 (片上内存)
延迟极低低,但有访问延迟
同步隐式同步 (指令本身)显式同步 (__syncthreads())
Bank Conflict (不经过共享内存)可能有 (如果访问模式不当)
适用范围仅限于 一个 warp (32 threads)可用于 整个 block (最多 1024 threads)
灵活性高 (直接指定源线程)较高 (通过数组索引)
代码复杂度较低 (循环次数固定)较低到中等

结论: 对于 warp 内的归约、广播等操作,Warp Shuffle 是首选,因为它更快、更简单、避免了 bank conflict。对于 block 内的归约,通常采用两级归约:先用 warp shuffle 在每个 warp 内归约,得到一个 warp 结果;然后将这些 warp 结果写入共享内存,再用传统的共享内存归约方法对这些 warp 结果进行最终归约。


总结

  • Warp Shuffle 是 CUDA 提供的硬件加速的 warp 内线程间通信机制。
  • 原理: 通过专用硬件指令,让线程直接从同一 warp 内其他线程的寄存器获取数据。
  • 优势: 超低延迟、高带宽、避免 bank conflict、无需显式同步
  • 典型应用:
    • Warp 内归约 (Reduction): 求和、求最大值等。
    • 广播 (Broadcast): 将一个线程的值分发给 warp 内所有线程。
    • 数据重排: 循环移位、洗牌。
    • Warp Scan (前缀和)
  • 最佳实践:
    • 优先在 warp 内操作中使用 __shfl_*_sync 系列指令。
    • 确保使用 mask 参数(通常 0xffffffff)并理解其作用。
    • 避免在分支发散的 warp 中使用 shuffle。
    • 对于 block 级归约,结合 warp shuffle 和共享内存归约(两级归约)。

掌握 Warp Shuffle 是编写高性能 CUDA 代码的重要一步,它能显著提升 warp 内协作操作的效率。

Stream

好的,我们来深入详解 CUDA 中的流 (Streams)。流是实现 CUDA 应用程序并发性 (Concurrency)重叠 (Overlap) 的核心机制,对于提升 GPU 利用率和整体性能至关重要。


1. 什么是 CUDA 流?(What is a CUDA Stream?)

  • 定义: CUDA 流是一个按顺序执行的命令队列。这些命令包括内核启动 (Kernel Launch)内存拷贝 (Memory Copy) 以及其他流式操作。
  • 关键特性:
    • 顺序性 (In-Order Execution): 在同一个流内,命令严格按照它们被提交到流的顺序执行。后一个命令必须等待前一个命令完成。
    • 并发性 (Concurrency): 不同流中的命令可以同时执行(并发),前提是 GPU 硬件资源(如 SM、内存带宽)允许。
    • 异步性 (Asynchrony): 大多数流操作(如 cudaMemcpyAsync, kernel<<<...>>>)是异步的。这意味着 CPU 线程在提交一个操作后会立即返回,无需等待该操作在 GPU 上完成。这允许 CPU 继续执行其他任务。
  • 默认流 (Default Stream / Null Stream):
    • 每个 CUDA 上下文都有一个隐式的默认流(通常用 0nullptr 表示)。
    • 所有没有显式指定流的操作(如 cudaMemcpy, kernel<<<...>>> 不带 , stream 参数)都会进入默认流。
    • 重要限制: 默认流是同步的,并且会阻塞所有其他流!这意味着任何在默认流中的操作(即使是异步的 cudaMemcpyAsync 如果流参数为 0)都会强制等待之前在所有流中提交的所有操作完成,并且会阻塞后续在所有流中提交的操作。因此,要实现真正的并发,必须避免使用默认流

2. 原理 (The Principle) - 如何实现并发与重叠

CUDA 流的并发能力依赖于 GPU 硬件的几个关键特性:

  1. 硬件队列 (Hardware Queues):
    • GPU 有多个硬件工作队列,例如:
      • 图形处理队列 (Graphics Processing Queue): 处理图形命令。
      • 计算队列 (Compute Queue): 处理 CUDA 内核。
      • 复制引擎队列 (Copy Engine Queue(s)): 专门处理内存拷贝(主机>设备,设备>设备)。
    • 现代 GPU 通常有多个独立的复制引擎(例如,一个用于主机到设备 H2D,一个用于设备到主机 D2H,有时还有设备到设备 D2D)。
  2. 流与队列的映射:
    • 当你在不同的流中提交操作时,CUDA 驱动会尝试将这些操作分发到不同的硬件队列上执行。
    • 例如:
      • 流 A 中的 H2D 拷贝 复制引擎 1 (H2D)
      • 流 B 中的 D2H 拷贝 复制引擎 2 (D2H)
      • 流 C 中的内核 计算队列
    • 只要资源不冲突,这些操作就可以真正地并行执行
  3. 重叠 (Overlap):
    • 计算与数据传输重叠: 这是最常见的优化目标。例如,当 GPU 正在执行一个内核时,复制引擎可以同时进行数据传输(H2D 或 D2H)。这可以隐藏数据传输的延迟。
    • 数据传输与数据传输重叠: 如果有多个复制引擎,H2D 和 D2H 拷贝可以同时进行。
    • 内核与内核重叠: 在支持超优先级 (Hyper-Q)CUDA Dynamic Parallelism 的较新架构上,多个内核可以在不同的 CUDA 工作队列上并发执行(前提是 SM 资源足够)。

3. 典型使用场景 (Typical Use Cases)

场景 1: 重叠计算与数据传输 (Overlapping Computation and Data Transfer)

这是流最经典和最重要的应用场景。目标是隐藏 PCIe 数据传输的高延迟

// 假设有 N 个数据块需要处理
const int N = 10;
const int size = 1024 * 1024 * sizeof(float); // 4MB per block
 
float *h_data[N]; // 主机数据数组
float *d_data;    // 设备数据缓冲区
float *d_result;  // 设备结果缓冲区
 
// 创建两个流
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
 
for (int i = 0; i < N; i += 2) {
    // --- 处理偶数块 (使用 stream1) ---
    if (i < N) {
        // 1. 异步拷贝第 i 个块的数据到设备 (H2D)
        cudaMemcpyAsync(d_data, h_data[i], size, cudaMemcpyHostToDevice, stream1);
 
        // 2. 在设备上启动处理第 i 个块的内核 (使用 stream1)
        //    这个内核会在 H2D 拷贝完成后开始执行
        processKernel<<<grid, block, 0, stream1>>>(d_data, d_result);
 
        // 3. 异步拷贝第 i 个块的结果回主机 (D2H)
        //    这个 D2H 拷贝会在内核完成后开始执行
        cudaMemcpyAsync(h_result[i], d_result, size, cudaMemcpyDeviceToHost, stream1);
    }
 
    // --- 处理奇数块 (使用 stream2) ---
    if (i + 1 < N) {
        // 1. 异步拷贝第 i+1 个块的数据到设备 (H2D)
        cudaMemcpyAsync(d_data, h_data[i+1], size, cudaMemcpyHostToDevice, stream2);
 
        // 2. 在设备上启动处理第 i+1 个块的内核 (使用 stream2)
        processKernel<<<grid, block, 0, stream2>>>(d_data, d_result);
 
        // 3. 异步拷贝第 i+1 个块的结果回主机 (D2H)
        cudaMemcpyAsync(h_result[i+1], d_result, d_result, size, cudaMemcpyDeviceToHost, stream2);
    }
 
    // 可选:同步流以确保当前批次完成
    // cudaStreamSynchronize(stream1);
    // cudaStreamSynchronize(stream2);
}
 
// 等待所有操作完成
cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);
 
// 清理
cudaStreamDestroy(stream1);
cudaStreamDestroy(stream2);
  • 分析:
    • stream1stream2 中的操作各自按顺序执行。
    • 关键重叠: 当 stream1processKernel 在 SM 上运行时(计算),stream2cudaMemcpyAsync (H2D) 可以通过复制引擎同时将下一个数据块传输到 GPU。同样,stream1 的 D2H 拷贝可以在 stream2 的内核运行时进行。
    • 效果: GPU 的计算资源和 PCIe 带宽被更充分地利用,总执行时间显著缩短,接近 max(总计算时间, 总传输时间),而不是两者的简单相加。

场景 2: 重叠 H2D 和 D2H 传输 (Overlapping H2D and D2H Transfers)

如果 GPU 有独立的 H2D 和 D2H 复制引擎,可以同时进行上传和下载。

cudaStream_t h2d_stream, d2h_stream;
cudaStreamCreate(&h2d_stream);
cudaStreamCreate(&d2h_stream);
 
for (int i = 0; i < N; ++i) {
    // 在 h2d_stream 中上传新数据块
    cudaMemcpyAsync(d_input, h_new_data[i], size, cudaMemcpyHostToDevice, h2d_stream);
 
    // 在 d2h_stream 中下载上一个处理完的结果块
    // 注意:这里假设 d_output 指向的是上一轮的结果
    cudaMemcpyAsync(h_processed_result[i], d_output, size, cudaMemcpyDeviceToHost, d2h_stream);
 
    // 启动内核处理新数据 (可以指定在 h2d_stream 或 d2h_stream,或另一个流)
    processKernel<<<grid, block, 0, h2d_stream>>>(d_input, d_output);
}
 
cudaStreamSynchronize(h2d_stream); // 或同步 d2h_stream
  • 分析: H2D 和 D2H 拷贝可以并行进行,进一步提高 PCIe 带宽利用率。

场景 3: 并行处理独立任务 (Parallel Processing of Independent Tasks)

当有多个独立的、可以并行执行的任务时。

// 例如:同时处理多个不同的图像或数据集
cudaStream_t stream[4];
for (int i = 0; i < 4; ++i) {
    cudaStreamCreate(&stream[i]);
}
 
// 启动 4 个独立的处理任务
for (int i = 0; i < 4; ++i) {
    cudaMemcpyAsync(d_input[i], h_input[i], size, cudaMemcpyHostToDevice, stream[i]);
    processTaskKernel<<<grid, block, 0, stream[i]>>>(d_input[i], d_output[i]);
    cudaMemcpyAsync(h_output[i], d_output[i], size, cudaMemcpyDeviceToHost, stream[i]);
}
 
// 等待所有任务完成
for (int i = 0; i < 4; ++i) {
    cudaStreamSynchronize(stream[i]);
}
  • 分析: 如果 GPU 资源(SM, 内存)足够,这 4 个任务可以真正地并发执行,总时间接近单个任务的时间。

场景 4: 与 CUDA Events 结合进行精细控制

cudaEvent_t 可以用来标记流中的特定点,并用于同步或测量时间。

cudaStream_t stream;
cudaEvent_t start, stop;
cudaStreamCreate(&stream);
cudaEventCreate(&start);
cudaEventCreate(&stop);
 
cudaEventRecord(start, stream); // 在 stream 中记录开始事件
 
// 提交流中的操作
cudaMemcpyAsync(d_data, h_data, size, cudaMemcpyHostToDevice, stream);
processKernel<<<grid, block, 0, stream>>>(d_data, d_result);
cudaMemcpyAsync(h_result, d_result, size, cudaMemcpyDeviceToHost, stream);
 
cudaEventRecord(stop, stream); // 在 stream 中记录结束事件
 
// CPU 可以做其他事...
 
// 等待 stream 完成所有操作
cudaStreamSynchronize(stream);
 
// 计算流内操作的耗时
float milliseconds = 0;
cudaEventElapsedTime(&milliseconds, start, stop);
  • 分析: Events 允许非阻塞的时间测量和更复杂的同步模式(例如,一个流等待另一个流中的某个事件发生才开始)。

4. 注意事项与最佳实践

  1. 避免默认流: 如前所述,使用默认流会破坏并发性。始终创建并使用自定义流。
  2. 资源竞争: 并发操作会竞争 GPU 资源(SM 执行单元、内存带宽、共享内存/L1 缓存容量)。过多的流或过大的内核可能会导致资源争用,反而降低性能。需要根据具体应用和硬件进行调优。
  3. 内存分配: 确保不同流使用的设备内存区域不冲突,或者操作是原子的。通常为不同流或不同任务分配独立的内存缓冲区是最安全的。
  4. 同步开销: cudaStreamSynchronize 是阻塞的,会等待流中所有操作完成。过度使用会破坏异步性。尽量使用异步操作和 events 进行非阻塞同步。
  5. 流的数量: 并非越多越好。流的数量应与可用的硬件队列和任务的独立性相匹配。通常 2-4 个流对于重叠 H2D/D2H/Compute 就足够了。过多的流会增加驱动开销。
  6. 统一内存 (Unified Memory): 在使用 cudaMallocManaged 分配的统一内存时,流和 events 同样适用,并且系统会自动管理数据在主机和设备间的迁移(通过页面错误和迁移),流可以帮助重叠迁移和计算。

总结

  • CUDA 流是管理命令执行顺序和实现并发的核心机制。
  • 核心价值: 实现计算与数据传输的重叠,隐藏延迟,提高 GPU 利用率。
  • 关键操作: cudaStreamCreate, cudaStreamDestroy, cudaStreamSynchronize, 以及所有带 , stream 参数的异步 API(cudaMemcpyAsync, kernel<<<...>>>)。
  • 典型场景:
    • 重叠计算与 H2D/D2H 传输(最常见)。
    • 重叠 H2D 和 D2H 传输。
    • 并行处理多个独立任务。
    • 结合 Events 进行时间测量和复杂同步。
  • 成功关键: 避免默认流,合理设计流的数量和操作顺序,确保有足够的硬件资源支持并发。

掌握 CUDA 流的使用,是将 CUDA 程序从“能运行”提升到“高性能”的关键一步。

Graph

好的,我们来深入讲解 CUDA Graphs 的原理和典型使用场景。CUDA Graphs 是在 CUDA 10.0 中引入的一项重要技术,旨在解决传统 CUDA 动态启动模型中的启动开销CPU 瓶颈问题,特别适用于重复执行的、固定模式的计算流程


1. 传统 CUDA 模型的痛点 (The Problem with Traditional Model)

在传统的 CUDA 编程中,我们通过在主机 (CPU) 代码中反复调用 API(如 kernel<<<...>>>, cudaMemcpyAsync)来启动内核和内存操作。这种方式存在以下问题:

  1. 高启动开销 (High Launch Overhead):

    • 每次调用 kernel<<<...>>>cudaMemcpyAsync,CPU 都需要与 GPU 驱动和硬件进行交互。
    • 这个过程涉及系统调用、驱动处理、命令构建和提交到 GPU 的硬件工作队列。
    • 对于小内核频繁启动的内核,这个开销可能变得非常显著,甚至超过内核本身的执行时间,导致 CPU 成为瓶颈。
  2. CPU 瓶颈 (CPU Bottleneck):

    • 如果应用程序需要在每个迭代中启动大量小操作,CPU 可能会花费大量时间在 CUDA API 调用上,无法及时提交新的工作,导致 GPU 等待,利用率下降。
  3. 缺乏全局优化机会:

    • 驱动在每次启动时只能看到单个操作,无法对整个计算流程进行跨操作的优化(如更优的调度、内存分配复用)。

2. 什么是 CUDA Graphs?(What is a CUDA Graph?)

CUDA Graphs 提供了一种静态的方式来表示和执行一个固定的、重复的 GPU 工作流。

  • 核心思想: 将一系列 CUDA 操作(内核启动、内存拷贝、事件、流同步等)及其依赖关系(通过流或显式依赖)捕获 (Capture) 成一个有向无环图 (DAG - Directed Acyclic Graph)。这个图被实例化 (Instantiate)优化后,可以被多次启动 (Launch),而无需 CPU 再次参与每个操作的启动决策

  • 关键组件:

    • Graph: 一个包含节点 (Nodes) 和边 (Edges) 的数据结构。节点代表 CUDA 操作(kernelNode, memcpyNode, memSetNode, hostNode, graphNode 等)。边代表节点之间的执行依赖(dependence)。
    • Graph Executable (Graph Instance): 从 Graph 创建的可执行实例。它包含了经过 CUDA 驱动和硬件优化后的低级命令序列。创建实例是相对昂贵的操作,但只需做一次。
    • Graph Launch: 启动一个 Graph Instance。这个操作非常轻量,CPU 开销极小,主要是向 GPU 提交一个“执行这个图”的指令。

3. 原理 (The Principle) - 工作流程

CUDA Graphs 的使用通常遵循以下三个步骤:

步骤 1: 图的创建 (Graph Creation)

有两种主要方式来创建图:

  • 方式 A: 显式图构建 (Explicit Graph Building)

    • 程序员手动创建 cudaGraph_t 对象。
    • 使用 cudaGraphAdd*Node 函数(如 cudaGraphAddKernelNode, cudaGraphAddMemcpyNode)向图中添加节点。
    • 使用 cudaGraphAddDependencies 显式地在节点之间添加依赖边。
    • 优点: 完全控制,灵活性高。
    • 缺点: 代码复杂,需要手动管理依赖。
  • 方式 B: 流捕获 (Stream Capture) - 更常用

    • 这是最直观和常用的方法,它利用了程序员已经熟悉的流 (Stream) 编程模型。
    • 开始捕获: 调用 cudaStreamBeginCapture(stream, mode)。指定一个流,之后在这个流上提交的所有操作将被记录到一个图中,而不是立即执行。
    • 记录操作: 在捕获模式下,像往常一样在指定的流(和可能相关的其他流)上启动内核 (kernel<<<...>>>) 和异步内存拷贝 (cudaMemcpyAsync)。这些调用不会立即执行,而是被记录下来。
    • 结束捕获: 调用 cudaStreamEndCapture(stream, &graph)。这会结束捕获,并返回一个 cudaGraph_t 对象,其中包含了从 BeginCaptureEndCapture 之间所有被记录的操作及其依赖关系(依赖关系由流的顺序性和 cudaStreamWaitEvent 等隐含)。
    • 优点: 代码改动小,可以复用现有的流式代码逻辑。

步骤 2: 图的实例化 (Graph Instantiation)

  • 调用 cudaGraphInstantiate(&graphExec, graph, ...)
  • 驱动和硬件会对 Graph 进行深度优化
    • 静态调度: 确定所有操作的最优执行顺序和时间。
    • 内存优化: 可能复用临时内存分配。
    • 命令序列化: 将整个工作流编译成一个高度优化的、可以直接提交给 GPU 硬件工作队列的低级命令包。
  • 这个过程可能涉及一些计算,因此只应在初始化阶段执行一次。

步骤 3: 图的启动与销毁 (Graph Launch and Cleanup)

  • 启动: 调用 cudaGraphLaunch(graphExec, stream)。这个调用非常快,CPU 开销极小。GPU 会按照图中定义的优化后顺序执行所有操作。
  • 重复启动: 可以多次调用 cudaGraphLaunch 来执行同一个 graphExec,实现低开销的重复执行。
  • 销毁: 使用完后,调用 cudaGraphDestroycudaGraphExecDestroy 释放资源。

4. 典型使用场景 (Typical Use Cases)

场景 1: 深度学习训练/推理中的固定迭代循环

这是 CUDA Graphs 最主要的应用场景。神经网络的训练或推理通常是一个重复的、结构固定的循环。

// --- 伪代码:使用流捕获创建训练迭代图 ---
 
// 1. 准备数据(第一次迭代)
loadBatch(h_data, 0); // CPU 加载第一批数据
cudaMemcpyAsync(d_data, h_data, size, cudaMemcpyHostToDevice, data_stream);
 
// 2. 开始图捕获
cudaStreamBeginCapture(data_stream, cudaStreamCaptureModeGlobal);
 
// --- 捕获一个完整的训练迭代 ---
// (a) 数据传输 (后续批次)
cudaMemcpyAsync(d_data, h_data, size, cudaMemcpyHostToDevice, data_stream); // 记录 H2D
 
// (b) 前向传播
forwardKernel<<<...>>>(d_data, d_output); // 记录内核
 
// (c) 计算损失
lossKernel<<<...>>>(d_output, d_labels, d_loss);
 
// (d) 后向传播
backwardKernel<<<...>>>(d_loss, d_grads);
 
// (e) 更新参数
updateKernel<<<...>>>(d_weights, d_grads);
 
// (f) 结果传输 (可选,如监控)
cudaMemcpyAsync(h_loss, d_loss, sizeof(float), cudaMemcpyDeviceToHost, result_stream);
 
// 注意:依赖关系由流(data_stream, result_stream)的顺序和同步隐含
// --- 捕获结束 ---
cudaStreamEndCapture(data_stream, &trainGraph);
 
// 3. 实例化图
cudaGraphInstantiate(&trainGraphExec, trainGraph, ...);
 
// 4. 正式训练循环(低开销)
for (int epoch = 0; epoch < num_epochs; ++epoch) {
    for (int batch = 0; batch < num_batches; ++batch) {
        // CPU 加载下一批数据 (与 GPU 工作重叠)
        if (batch < num_batches - 1) {
            loadBatch(h_data, batch + 1);
        }
 
        // 启动图执行本次迭代
        // CPU 几乎不参与,GPU 按图执行整个流程
        cudaGraphLaunch(trainGraphExec, launch_stream);
 
        // 可能需要同步以获取结果或确保完成
        // cudaStreamSynchronize(result_stream); // 如果需要 h_loss
    }
}
 
// 5. 清理
cudaGraphExecDestroy(trainGraphExec);
cudaGraphDestroy(trainGraph);
  • 优势: 将一个包含多个小内核启动和内存拷贝的复杂迭代循环,变成了一个单一的、低开销的 cudaGraphLaunch 调用。显著降低了 CPU 开销,提高了 GPU 利用率,尤其在小批量或小模型上效果显著。

场景 2: 科学计算中的固定迭代算法

许多迭代算法(如 Jacobi 迭代、共轭梯度法)具有固定的计算模式。

// 捕获一个 Jacobi 迭代步骤
cudaStreamBeginCapture(compute_stream, mode);
for (int step = 0; step < 1; ++step) { // 只记录一次迭代
    jacobiKernel<<<...>>>(d_grid_new, d_grid_old);
    swap(d_grid_new, d_grid_old); // 逻辑在图外处理,或用 hostNode
    // 如果需要检查收敛,可能需要 hostNode 或外部检查
}
cudaStreamEndCapture(compute_stream, &jacobiGraph);
// 实例化和启动...

场景 3: 多阶段图像/信号处理流水线

一个固定的处理流水线(如:滤波 变换 分析)。

cudaStreamBeginCapture(pipeline_stream, mode);
filterKernel<<<...>>>(d_input, d_filtered);
transformKernel<<<...>>>(d_filtered, d_transformed);
analyzeKernel<<<...>>>(d_transformed, d_result);
cudaStreamEndCapture(pipeline_stream, &pipelineGraph);
// 实例化和启动...

5. 与 CUDA 流的对比

特性CUDA 流 (Streams)CUDA Graphs
模型动态 (Dynamic)静态 (Static)
开销每次启动有 CPU 开销首次实例化开销大,后续启动开销极小
优化有限,驱动在运行时决策深度优化(静态调度、内存复用)
灵活性 (每次迭代可动态改变操作) (图结构固定,参数可更新)
适用场景通用,并发,重叠重复执行的、固定模式的工作流
CPU 负担较高 (频繁 API 调用)极低 (启动后 CPU 可释放)
内存每次操作可能涉及分配/释放可能复用临时内存

6. 注意事项与最佳实践

  1. 适用性: CUDA Graphs 只适用于模式固定、重复执行的工作流。如果每次迭代的操作、内核参数或数据依赖关系都不同,则不适合使用。
  2. 参数更新: 虽然图的结构是固定的,但可以通过 cudaGraphExecKernelNodeSetParams 等 API 在启动前更新内核的参数(如 gridDim, blockDim, kernelParams)或内存拷贝的地址。这提供了有限的灵活性。
  3. Host Code: 图中可以包含 hostNode,用于在图执行过程中调用主机函数。但这会引入同步点,可能破坏性能优势,应谨慎使用。
  4. 错误处理: 图实例化或启动时的错误处理需要特别注意。
  5. 性能收益: 在小内核、高频率启动的场景下收益最大。对于大型、长时间运行的内核,启动开销占比小,使用 Graphs 的收益可能不明显。
  6. 工具: 使用 Nsight Systems 可以可视化和分析 CUDA Graphs 的执行,确认其是否按预期工作。

总结

  • CUDA Graphs 是一种将固定模式的 GPU 工作流捕获为静态图的技术。
  • 核心优势: 消除重复的启动开销,实现极低的 CPU 开销更高的 GPU 利用率
  • 工作流程: 捕获 (Capture) 实例化 (Instantiate) 启动 (Launch)
  • 主要方法: 流捕获 (Stream Capture) 是最常用的方式。
  • 典型场景: 深度学习训练/推理循环、固定迭代的科学计算、多阶段信号处理流水线。
  • 与流的关系: Graphs 不是取代流,而是构建在流之上的更高层次的优化。捕获过程利用流来定义依赖,而图的启动本身也可以在流中进行。

CUDA Graphs 是优化高性能、低延迟 CUDA 应用程序(尤其是深度学习框架)的关键技术,它将工作流的“编译”和“执行”分离,实现了接近“零开销”的重复执行。

Float 4

Grid stride loops

float 4