CUDA性能优化的核心在于理解GPU的硬件架构,并围绕“最大化并行度”和“最小化访存延迟”这两个目标来设计你的代码。下面我会从几个关键的实战层面,结合代码示例,为你拆解CUDA的优化技巧。
基石一:合并且向量化的内存访问
GPU对内存的访问有严格的要求,不合理的访问模式会严重拖慢性能。
• 合并访问 (Coalesced Access):这是最基础也最重要的一条。一个Warp(32个线程)在访问全局内存时,所访问的地址应当连续且对齐。这样,GPU可以将这32个线程的访问合并成尽可能少的几次内存事务,大幅提升带宽利用率。
• 向量化内存访问:在合并访问的基础上,可以通过使用float2、float4或int4等CUDA内置的向量数据类型,让编译器生成一次加载64位或128位数据的指令(如LDG.E.64、LDG.E.128)。这样做能减少指令数量、降低延迟,并进一步提高带宽利用率,对于那些受带宽限制的Kernel效果显著。
代码示例:使用float2实现向量化加载
cpp
// 标量加载版本
globalvoid scalar_copy(float* d_in, float* d_out, int N) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
for (int i = idx; i < N; i += blockDim.x * gridDim.x) {
d_out[i] = d_in[i]; // 每次加载32位
}
}
// 向量化加载版本 (使用 float2)
globalvoid vectorized_copy(float2* d_in, float2* d_out, int N) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
// N必须为偶数,或处理好边界
for (int i = idx; i < N/2; i += blockDim.x * gridDim.x) {
d_out[i] = d_in[i]; // 编译器自动生成128位加载指令 (LDG.E.128)
}
}
注意:使用向量化类型时,需要确保数据指针是对齐的。设备分配的内存默认是对齐的,但指针偏移时需谨慎。
基石二:挖掘片上内存的潜力
GPU的共享内存(Shared Memory)是位于芯片上的高速缓存,延迟远低于全局内存(几十个cycle vs 几百个cycle)。将频繁访问的数据缓存在共享内存中,是优化访存密集型Kernel的关键。
经典实战:优化矩阵乘法
在矩阵乘法C = A * B中,A和B的数据会被反复使用。若不优化,每次计算都需要从全局内存读取,效率极低。
优化的核心思想是分块(Tiling):将矩阵划分为小块(Tile),每个线程块负责计算一个输出Tile。在计算前,将需要的A和B的子块从全局内存加载到共享内存中,然后所有线程再从共享内存中读取数据进行计算,从而大幅减少对全局内存的访问。
代码示例:利用共享内存分块计算
cpp
template
globalvoid matrixMulShared(float* A, float* B, float* C, int M, int N, int K) {
// 声明共享内存,大小由模板参数决定
sharedfloat As[BLOCK_SIZE][BLOCK_SIZE];
sharedfloat Bs[BLOCK_SIZE][BLOCK_SIZE];
int bx = blockIdx.x, by = blockIdx.y; int tx = threadIdx.x, ty = threadIdx.y; float tmp = 0.0f; // 沿K维度分步加载 for (int k = 0; k < K; k += BLOCK_SIZE) { // 协同加载A和B的一个Tile到共享内存 As[ty][tx] = A[by * BLOCK_SIZE * K + (k + tx)]; Bs[ty][tx] = B[(k + ty) * N + bx * BLOCK_SIZE + tx]; __syncthreads(); // 确保整个Tile加载完成 // 在共享内存上计算子矩阵乘加 for (int i = 0; i < BLOCK_SIZE; ++i) { tmp += As[ty][i] * Bs[i][tx]; } __syncthreads(); // 确保在当前Tile计算完成后再加载下一批 } // 将结果写回全局内存 C[by * BLOCK_SIZE * N + bx * BLOCK_SIZE + tx] = tmp;}
进阶技巧:内核融合与Warp级优化
• 内核融合 (Kernel Fusion):当多个Kernel串行执行,且中间结果需要保存在全局内存时,可以考虑将它们合并成一个Kernel。这样做的好处是:
- 减少全局内存访问:中间数据可以直接在寄存器或共享内存中传递,而不用写入再读回全局内存。
- 减少Kernel启动开销:减少了CPU向GPU发送命令的次数。
• Warp级优化:同一个Warp内的线程以锁步(lock-step)方式执行。利用这个特性,在Warp内部进行规约(Reduction)时,不需要使用__syncthreads()进行同步,因为它们在硬件上就是同步的。这可以节省宝贵的同步开销。
代码示例:展开最后的Warp进行规约
cpp
// 专门用于Warp内规约的函数
devicevoid warpReduce(volatile int* sdata, int tid) {
sdata[tid] += sdata[tid + 32];
sdata[tid] += sdata[tid + 16];
sdata[tid] += sdata[tid + 8];
sdata[tid] += sdata[tid + 4];
sdata[tid] += sdata[tid + 2];
sdata[tid] += sdata[tid + 1];
}
globalvoid reduceKernel(int* g_in, int* g_out) {
externsharedint sdata[];
int tid = threadIdx.x;
// … 将数据加载到sdata …
// 普通规约循环,当剩余活跃线程数大于32时使用 for (unsigned int s = blockDim.x / 2; s > 32; s >>= 1) { if (tid < s) sdata[tid] += sdata[tid + s]; __syncthreads(); } // 当只剩下32个线程时,调用warpReduce,无需__syncthreads if (tid < 32) { warpReduce(sdata, tid); } if (tid == 0) { g_out[blockIdx.x] = sdata[0]; }}
代码中使用了volatile关键字,这是为了防止编译器对共享内存的访问进行过度优化,确保每次读写都直接从共享内存进行。
总结与实战建议
CUDA优化是一个循序渐进的过程。一个实用的学习路径是从一个简单的实现开始(如基础矩阵乘或归约),然后逐步应用上述技巧:确保合并访问 -> 使用共享内存缓存数据 -> 考虑向量化加载 -> 尝试内核融合和Warp级展开。每一步都应通过性能分析工具(如NVIDIA Nsight Systems/Compute)来验证和量化优化的效果,这样才能真正理解每个技巧对性能带来的影响。