第一章:CUDA编程基础回顾

各位同学好,我是你们的老朋友。今天咱们聊聊CUDA编程里最基础、也最绕不开的几个概念。说实话,我见过不少新手一上来就追各种花里胡哨的优化技巧,结果连Kernel Launch的配置都写不对——这就像盖楼不打地基,迟早要塌。

这一章,我会带着大家把Grid-Block-Thread映射、各种内存的使用、同步和原子操作这些基本功再过一遍。嗯,都是些老生常谈的东西,但相信我,里面有不少坑是我自己踩过的。

1.1 Kernel Launch:你写的第一个CUDA函数

Kernel Launch,说白了就是让GPU干活的那条指令。你写一个函数,前面加个__global__,然后调用的时候用三括号语法<<<grid, block>>>。就这么简单?其实不然。

// 一个简单的向量加法Kernel
__global__ void vecAdd(float* A, float* B, float* C, int N) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < N) {
        C[idx] = A[idx] + B[idx];
    }
}

// 调用方式
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

我个人习惯把blocksPerGrid和threadsPerBlock分开定义,这样调试的时候一目了然。你在项目里可能会遇到一个问题:threadsPerBlock设多大合适?我建议一般取128到512之间,具体要看你的Kernel里用了多少寄存器。

注意:每个Block的线程数不能超过1024(现代GPU)。而且,如果你用了Shared Memory,这个上限还会更低。我曾经在项目里设了1024个线程,结果每个线程用了太多Shared Memory,直接导致Kernel启动失败——嗯,那会儿排查了好久。

1.2 Grid-Block-Thread映射:搞清楚谁在干活

你想想看,GPU里有成千上万个线程在跑。怎么知道哪个线程处理哪个数据?靠的就是这个映射关系。

  • threadIdx:线程在Block内的索引,可以是1D、2D或3D
  • blockIdx:Block在Grid内的索引
  • blockDim:Block的维度大小
  • gridDim:Grid的维度大小

最常见的映射公式:

int tid = threadIdx.x + blockIdx.x * blockDim.x;

这个公式我闭着眼睛都能写出来。但要注意,如果你的数据是2D的,比如图像处理,那就得用2D的映射:

int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
int idx = row * width + col;

为什么会这样?因为GPU的线程调度是以Warp(32个线程)为单位的。如果你用1D映射处理2D数据,访存效率会大打折扣。我在做矩阵转置的时候吃过这个亏,后来改成2D映射,性能直接翻倍。

核心要点:映射方式决定了你的访存模式。尽量让相邻线程访问连续的内存地址,这样才能利用合并访问(Coalesced Access)。

1.3 内存三兄弟:Global、Local、Shared

CUDA里的内存类型,说白了就三种:Global Memory(全局内存)、Local Memory(本地内存)、Shared Memory(共享内存)。它们各有各的脾气。

内存类型 位置 访问速度 作用域 生命周期
Global Memory 显存 慢(~400周期) 所有线程 程序运行期间
Local Memory 显存(寄存器溢出) 慢 单个线程 Kernel执行期间
Shared Memory 片上 快(~5周期) 同一个Block Block生命周期

Global Memory是最大的,但也是最慢的。我建议你尽量少直接读写Global Memory,能复用数据就复用。Shared Memory是块宝,同一个Block里的线程可以共享数据,速度比Global快两个数量级。

// 使用Shared Memory做矩阵分块乘法
__global__ void matMulShared(float* A, float* B, float* C, int N) {
    __shared__ float As[TILE_SIZE][TILE_SIZE];
    __shared__ float Bs[TILE_SIZE][TILE_SIZE];
    
    int row = blockIdx.y * TILE_SIZE + threadIdx.y;
    int col = blockIdx.x * TILE_SIZE + threadIdx.x;
    float sum = 0.0f;
    
    for (int t = 0; t < N / TILE_SIZE; t++) {
        As[threadIdx.y][threadIdx.x] = A[row * N + t * TILE_SIZE + threadIdx.x];
        Bs[threadIdx.y][threadIdx.x] = B[(t * TILE_SIZE + threadIdx.y) * N + col];
        __syncthreads();
        
        for (int k = 0; k < TILE_SIZE; k++) {
            sum += As[threadIdx.y][k] * Bs[k][threadIdx.x];
        }
        __syncthreads();
    }
    C[row * N + col] = sum;
}

这段代码里,__shared__声明了Shared Memory数组。__syncthreads()是同步屏障,确保所有线程都加载完数据再开始计算。我刚开始写这个的时候,忘了加第二个__syncthreads(),结果数据被覆盖了,算出来的结果全是错的——嗯,这种错误排查起来特别痛苦。

小技巧:Shared Memory的大小是有限的,一般每个Block最多48KB(有些GPU可以到96KB或更多)。如果你的数据量太大,可以考虑分块处理,或者用寄存器来存。

1.4 同步与原子操作:别让线程打架

多线程编程最头疼的问题就是数据竞争。CUDA里提供了两种机制来解决:同步(Synchronization)和原子操作(Atomic Operations)。

同步:__syncthreads()

这个函数是Block级别的同步屏障。所有线程必须都执行到这一行,才能继续往下走。我建议你在以下场景必须使用:

  • Shared Memory读写之间
  • 一个线程写数据,另一个线程读同一个数据
  • 分块算法中,不同阶段之间

但要注意,__syncthreads()不能用在if-else分支里。如果一个线程进了if,另一个线程进了else,那就会死锁。我曾经在代码里写过这样的逻辑,结果程序卡死了,排查了半天才发现是同步位置不对。

原子操作:atomicAdd、atomicCAS等

原子操作用于处理全局变量的并发更新。比如你要统计数组里有多少个正数:

__global__ void countPositive(float* data, int* count, int N) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < N && data[idx] > 0) {
        atomicAdd(count, 1);
    }
}

这里atomicAdd保证了对count的累加是线程安全的。但原子操作是有性能代价的——它会让多个线程串行化。我建议你尽量少用原子操作,能用并行规约(Parallel Reduction)解决的问题,就别用原子操作。

性能警告:原子操作在Global Memory上的开销很大。如果必须用,可以考虑先在Shared Memory里做局部累加,最后再原子操作写回Global Memory。这样能减少全局原子操作的次数。

1.5 本章知识体系

下面这张图是我自己画的,把这一章的核心逻辑串起来了。你仔细看看,应该能对CUDA编程基础有个整体的认识。

CUDA编程基础回顾 - 知识体系 Kernel Launch Grid-Block-Thread映射 Global/Local/Shared Memory 同步与原子操作 1D/2D/3D索引 合并访问 Global Memory Shared Memory __syncthreads() atomicAdd/CAS 核心原则:访存模式决定性能 合并访问 + 共享内存复用 + 减少原子操作

这张图把Kernel Launch、线程映射、内存类型、同步原子操作串在了一起。你写代码的时候,脑子里要有这张图——每个线程在哪个Block里,访问哪块内存,需不需要同步,心里都得有数。

好了,这一章的内容就到这里。CUDA编程基础虽然看起来简单,但真正用好用对,还是需要大量实践的。我建议你回去把上面的代码示例都跑一遍,改改参数,看看性能变化。嗯,动手才是最好的学习方式。

可可精品资料库