CUDA 学习记录
从 Thread / Block / Warp / SP 的并行模型,到一维、二维全局坐标计算的入门笔记
· 4 min read
CUDA 并行实现 #
Thread —— 最小逻辑并行单元 #
CUDA 里的 Thread 可以先理解成最小的并行单元
比如这个 kernel:
__global__ void add_one(float* x, float* y, int n) {
int i = threadIdx.x;
if (i < n) {
y[i] = x[i] + 1.0f;
}
}
如果你启动 10 个 thread,那么每个 thread 都会执行这一段代码。
区别在于:
threadIdx.x
每个 thread 的值不同。
例如:
thread 0: threadIdx.x = 0
thread 1: threadIdx.x = 1
thread 2: threadIdx.x = 2
...
thread 9: threadIdx.x = 9
所以虽然所有 thread 执行同一份代码,但它们处理的数据不同。
这就是 CUDA 的基本思想。
为什么还需要 Block #
如果只有 Thread,会有一个问题:
一次 kernel 可能要启动几百万、几千万个 thread。
CUDA 不会把所有 thread 扔成一大坨,而是把它们分组,这个组就叫 Block。
可以理解成:
Thread:单个学生
Block:一个班级
Grid:整个年级
比如我们启动:
kernel<<<3, 4>>>(...);
意思是:
3 个 Block
每个 Block 里有 4 个 Thread
所以总 thread 数是:
3 × 4 = 12 个 thread
threadIdx.x 只是在 当前 Block 内部编号。
所以 Block 0 里面有一个 threadIdx.x = 0
Block 1 里面也有一个 threadIdx.x = 0
全局编号:
int i = blockIdx.x * blockDim.x + threadIdx.x;
其中:
blockIdx.x:当前是第几个 Block
blockDim.x:每个 Block 有多少个 Thread
threadIdx.x:当前 Thread 在 Block 内部的编号
假设:
kernel<<<3, 4>>>(...);
那么:
gridDim.x = 3
blockDim.x = 4
SM 是什么 #
Streaming Multiprocessor,流式多处理器,对应逻辑上的 Block。
刚才讲的是 CUDA 的软件抽象,现在看硬件真实执行。
假设 GPU 有 2 个 SM
启动:
kernel<<<3, 4>>>(...);
也就是 3 个 Block:
Block 0
Block 1
Block 2
GPU 可能这样调度:
SM 0 执行 Block 0
SM 1 执行 Block 1
Block 2 先等着
等某个 SM 空出来后:
SM 0 执行完 Block 0
SM 0 再执行 Block 2
你不需要写:
Block 0 去 SM 0
Block 1 去 SM 1
Block 2 去 SM 0
这是硬件调度器自动做的。
这个地方就要针对硬件级别进行优化。
Warp 是什么 #
Warp 是 GPU 硬件调度和执行的最小物理单元,通常包含 32 个 Thread。
也就是说,你写的是 Thread,但 GPU 不会真的一个 thread 一个 thread 调度,它会把 thread 打包成 Warp。
比如你写:
kernel<<<1, 256>>>(...);
意思是:
1 个 Block
256 个 Thread
硬件会把这 256 个 Thread 分成:
256 / 32 = 8 个 Warp
也就是:
Warp 0: thread 0 ~ 31
Warp 1: thread 32 ~ 63
Warp 2: thread 64 ~ 95
...
Warp 7: thread 224 ~ 255
你不用手动创建 Warp。
Warp 是硬件自动形成的。
- Thread 是你写代码时看到的逻辑单位
- Warp 是硬件真正调度执行的单位
最好尽量让 CUDA 线程组织"契合 Warp" —— 32 的倍数,假如 33:
Thread 0 ~ 31 → Warp 0,满的
Thread 32 → Warp 1,只有 1 个有效线程
SP 又是什么 #
SP 是真正进行加减乘除的计算执行单元。
GPU 内部会把 Warp 的指令发给执行单元去算。
所以关系是:
你控制:Grid / Block / Thread
硬件控制:Block 放到哪个 SM,Thread 怎么组成 Warp,Warp 怎么用 SP 执行
大致映射关系是:
一个 Kernel 启动一个 Grid
一个 Grid 包含很多 Block
一个 Block 会被调度到某个 SM 上执行
一个 Block 里的 Thread 会被硬件切成多个 Warp
Warp 才是硬件真正调度执行的单位
Warp 内的指令最终由 SM 内部执行单元完成
CUDA 软件模型:
Grid
└── Block
└── Thread
GPU 硬件执行模型:
GPU
└── SM
└── Warp
└── 各种 Core / 执行单元
最重要的是:
Block → SM
Thread → Warp
这不是手动指定的,是硬件自动安排的。
全局坐标计算 #
一维坐标计算 #

一维数组 kernel,基本都是这个模板:
__global__ void kernel(float* x, float* y, int N) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
// 一定要写边界,可能会出现空余的线程 (比如 3x4 > 10)
if (i < N) {
y[i] = ...;
}
}
主机端启动:
int blockSize = 256;
int gridSize = (N + blockSize - 1) / blockSize; // 向上取整,覆盖所有的数据
kernel<<<gridSize, blockSize>>>(x, y, N);
二维坐标计算 #
二维数据常见于图像、矩阵和二维 Tensor。
比如一张图像有:
width = 图像宽度,也就是每一行有多少个像素
height = 图像高度,也就是一共有多少行
二维 kernel 里通常先计算当前线程负责的列和行:

// 列方向,当前块之前的数量 * 每块宽度 + 当前块内的线程偏移
int col = blockIdx.x * blockDim.x + threadIdx.x;
// 行方向,当前块之前的数量 * 每块高度 + 当前块内的线程偏移
int row = blockIdx.y * blockDim.y + threadIdx.y;
但是内存本质是一维连续空间,不是真正的二维格子。
所以还要把二维坐标 (row, col) 转成一维线性地址:
// 我们只需要利用行优先规则转换
if (row < height && col < width) {
int global_idx = row * width + col; // 转为一维线性地址
}
这里的关键是:
width 是每一行有多少个元素。
row * width 表示跳过前面 row 行。
+ col 表示当前行内部的列偏移。
例如:
width = 4
height = 3
二维数据可以看成:
第 0 行: (0,0) (0,1) (0,2) (0,3)
第 1 行: (1,0) (1,1) (1,2) (1,3)
第 2 行: (2,0) (2,1) (2,2) (2,3)
如果当前线程负责:
row = 2
col = 1
那么一维下标是:
global_idx = row * width + col;
= 2 * 4 + 1;
= 9;
在 CUDA 里,坐标计算不仅影响代码是否正确,也会影响性能。
一个很重要的黄金法则是:
让 x 维度的线程连续访问连续的内存地址。
原因是:在 CUDA 中,一个 Warp 通常包含 32 个 Thread,而这些 Thread 在线程编号上是连续的。对于二维 Block 来说,Warp 内的线程通常会优先沿着 threadIdx.x 方向连续排列。
也就是说,如果我们这样写:
int col = blockIdx.x * blockDim.x + threadIdx.x;
int row = blockIdx.y * blockDim.y + threadIdx.y;
int idx = row * width + col;
那么同一个 Warp 内相邻的 Thread 很可能访问:
thread 0 -> data[row * width + col + 0]
thread 1 -> data[row * width + col + 1]
thread 2 -> data[row * width + col + 2]
...
thread 31 -> data[row * width + col + 31]
这些地址在内存中是连续的。
这对 GPU 非常友好,因为相邻 Thread 访问相邻地址时,GPU 可以更高效地合并这些 global memory 访问,也就是所谓的 coalesced memory access.
因此,对于行优先存储的数据,比如 C/C++ 数组、图像、矩阵、PyTorch contiguous tensor,通常应该让:
x / col 方向对应内存中连续变化的维度
也就是让 threadIdx.x 负责访问连续的列方向。
例如二维图像中,推荐:
int col = blockIdx.x * blockDim.x + threadIdx.x;
int row = blockIdx.y * blockDim.y + threadIdx.y;
int idx = row * width + col;
而不是让相邻线程跨行、跨 stride 访问:
int idx = col * height + row; // 对行优先数据通常不友好
可以把这个规则记成一句话:
CUDA 里不仅要让线程“算对自己的位置”,还要让相邻线程“访问相邻的数据”。
这就是为什么二维图像处理中常见的 Block 配置会选择:
dim3 block(16, 16);
或者:
dim3 block(32, 8);
它们的总线程数都是 256,都是 32 的倍数,能比较好地契合 Warp。
其中 32 × 8 的好处是:x 方向正好有 32 个线程,一个 Warp 更容易覆盖一整段连续的列方向数据,因此在行优先内存布局下更容易形成连续访存。
所以,全局坐标计算的最终目标不只是知道自己处理哪个元素
还要进一步做到:
让相邻线程处理相邻元素
这就是后续 CUDA kernel 优化的起点。