阶段 1 · 起步02 / 15约 22 分钟cuda/02_vector_add.cu
线程层级:grid / block / thread 的索引算术
把一维、二维数据映射到线程上,以及为什么每个 kernel 都要写边界检查
学完这一课你会
- 熟练推导全局线程索引,包括一维和二维情形
- 知道为什么 grid 尺寸要向上取整,以及随之而来的越界风险
- 掌握 grid-stride loop 这个比裸索引更健壮的写法
- 建立「线程 ↔ 数据元素」的映射直觉
CUDA 的并行模型是一个两级层次结构:grid 由若干 block 组成,block 由若干 thread 组成。这个划分不是为了好看——它直接对应硬件:一个 block 会被整体分配到一个 SM 上执行,block 内的线程可以共享 shared memory、可以用 __syncthreads() 互相等待;而不同 block 之间什么都共享不了。
grid
┌───────────────┬───────────────┬───────────────┐
│ block 0 │ block 1 │ block 2 │
│ t0 t1 t2 t3 │ t0 t1 t2 t3 │ t0 t1 t2 t3 │
└───────────────┴───────────────┴───────────────┘
0 1 2 3 4 5 6 7 8 9 10 11 ← 全局索引
global = blockIdx.x * blockDim.x + threadIdx.x
↑ ↑ ↑
我是第几个块 每块多少线程 块内第几个线程一维:向量加法
CUDA C++
1__global__ void vecAdd(const float* a, const float* b, float* c, int n) {2 int i = blockIdx.x * blockDim.x + threadIdx.x;3 if (i < n) { // 边界检查:不能省4 c[i] = a[i] + b[i];5 }6}为什么一定要写 if (i < n)?因为 block 的线程数通常取 128/256 这类 2 的幂,而 n 是任意值。要覆盖所有元素,grid 尺寸必须向上取整,于是最后一个 block 里必然有一部分线程的索引超出了数组范围。少了这行检查,就是一次显存越界写——轻则结果错乱,重则踩坏别的数据结构。
CUDA C++
1int n = 1'000'000;2int threads = 256;3int blocks = (n + threads - 1) / threads; // = ceil(n / threads) = 39074 5vecAdd<<<blocks, threads>>>(d_a, d_b, d_c, n);6// 3907 * 256 = 1'000'192 个线程,最后 192 个被 if 挡住grid-stride loop:更健壮的写法
「一个线程处理一个元素」在数据量特别大或特别小的时候都不理想。grid-stride loop 让 grid 尺寸和数据规模解耦:不管你启动多少线程,每个线程都用固定步长把整个数组扫一遍。这样同一份 kernel 可以用一个针对硬件调优过的 grid 尺寸跑任意规模的数据。
CUDA C++
1__global__ void vecAddStride(const float* a, const float* b, float* c, int n) {2 int stride = gridDim.x * blockDim.x; // 整个 grid 的线程总数3 for (int i = blockIdx.x * blockDim.x + threadIdx.x;4 i < n;5 i += stride) {6 c[i] = a[i] + b[i];7 }8}- ▸规模无关:n 是 1000 还是 10 亿,同一个 grid 配置都能正确跑完。
- ▸访问依然合并:相邻线程在每一轮迭代里访问的仍是相邻地址,不破坏内存合并(下一阶段会讲)。
- ▸便于调试:把 grid 设成
<<<1, 1>>>就退化成串行执行,方便你对拍结果。 - ▸复用寄存器:循环外的常量计算只做一次,摊薄到多个元素上。
二维:图像和矩阵
处理矩阵时用二维 grid 更自然。注意一个约定:threadIdx.x 应该对应行内连续的方向(也就是列号),因为 warp 内相邻的线程是按 .x 优先编号的,只有让 .x 走连续内存,访问才能合并。这一点写反了,性能可能差 5 倍以上。
CUDA C++
1__global__ void scaleMatrix(float* m, int rows, int cols, float k) {2 // x → 列(内存连续方向),y → 行3 int col = blockIdx.x * blockDim.x + threadIdx.x;4 int row = blockIdx.y * blockDim.y + threadIdx.y;5 6 if (row < rows && col < cols) {7 m[row * cols + col] *= k; // 行主序:同一 warp 覆盖连续的 col8 }9}10 11// host 侧12dim3 block(32, 8); // 256 线程,x 维正好一个 warp13dim3 grid((cols + block.x - 1) / block.x,14 (rows + block.y - 1) / block.y);15scaleMatrix<<<grid, block>>>(d_m, rows, cols, 2.0f);block 大小怎么选
| block 尺寸 | 评价 |
|---|---|
| 32 | 太小。每个 SM 能驻留的 block 数有上限(通常 16~32),线程总量上不去,占用率被卡死 |
| 128 / 256 | 默认首选。绝大多数 kernel 从这里起步,兼顾占用率与调度灵活性 |
| 512 | 适合每线程寄存器少、需要大块 shared memory 协同的 kernel |
| 1024 | 硬件上限。寄存器压力极大,通常反而更慢,除非算法确实需要 |
| 非 32 倍数 | 永远不要。比如 100,会让每个 block 的最后一个 warp 有 28 个线程空转 |
自测
先自己在心里回答一遍,再展开对照。答不上来的说明这一段值得重读。