1.2 CUDA 编程模型
深入理解 CUDA 的 Grid/Block/Thread 三级线程层次结构、线程索引计算方法和 Kernel 启动配置策略,这是编写高效 GPU 程序的核心基础。
📑 目录
- 1. 异构计算模型概览
- 2. Grid/Block/Thread 三级线程层次
- 3. 线程索引计算
- 4. Kernel 函数与启动语法
- 5. Block 大小选择策略
- 6. Warp:硬件调度的基本单位
- 7. 多维索引与实际应用
- 8. 占用率与性能
- 总结
1. 异构计算模型概览
CUDA 程序运行在一个异构系统上:CPU(Host)负责控制逻辑和串行代码,GPU(Device)负责大规模并行计算。想象一个工厂——CPU 是厂长,负责下达指令、调配资源;GPU 是拥有上万工人的车间,一旦接到任务就全员并行开工。
1.1 程序执行流程
sequenceDiagram
participant Host as CPU (Host)
participant Device as GPU (Device)
Host->>Host: 初始化数据
Host->>Device: cudaMemcpy (数据传输到 GPU)
Host->>Device: kernel<<<grid, block>>>() 启动
Device->>Device: 数千线程并行执行
Device->>Host: cudaMemcpy (结果传回 CPU)
Host->>Host: 后续处理
1.2 三种函数修饰符
CUDA 用修饰符来标记函数在哪里执行、由谁调用:
| 修饰符 | 执行位置 | 调用方 | 用途 |
|---|---|---|---|
__global__ |
GPU | CPU(或 GPU 动态并行) | Kernel 入口函数 |
__device__ |
GPU | GPU | Kernel 内部调用的辅助函数 |
__host__ |
CPU | CPU | 普通 CPU 函数(默认) |
1 | // 可以组合使用:同时为 CPU 和 GPU 编译 |
2. Grid/Block/Thread 三级线程层次
CUDA 的核心设计哲学是层次化并行:将海量线程组织为三级结构,既方便程序员思考,也贴合 GPU 硬件的物理布局。
2.1 概念模型
打个比方:
- Grid(网格) = 整个学校——一次 Kernel 启动产生的所有线程的集合
- Block(线程块) = 一个班级——Block 内线程可以协作(共享内存、同步)
- Thread(线程) = 一个学生——最小的执行单位
2.2 维度与索引
Grid 和 Block 都可以是 1D、2D 或 3D 的:
1 | // 1D:处理向量 |
2.3 硬件限制
| 限制项 | 最大值(Compute Capability ≥ 8.0) |
|---|---|
| Block 每维最大线程数 | x:1024, y:1024, z:64 |
| Block 内最大线程总数 | 1024 |
| Grid 每维最大 Block 数 | x:$2^{31}-1$, y:65535, z:65535 |
| 每个 SM 最大活跃线程数 | 2048(sm_80)/ 1536(sm_89) |
| 每个 SM 最大活跃 Block 数 | 16~32(取决于架构) |
⚠️ 注意:Block 内线程总数不能超过 1024,即 blockDim.x * blockDim.y * blockDim.z ≤ 1024。
3. 线程索引计算
每个线程都能通过内置变量获取自身在层次结构中的位置,进而计算出它应该处理哪一份数据。
3.1 内置变量
| 变量 | 类型 | 含义 |
|---|---|---|
threadIdx |
dim3 |
线程在所属 Block 内的索引 |
blockIdx |
dim3 |
Block 在 Grid 内的索引 |
blockDim |
dim3 |
每个 Block 的维度(线程数) |
gridDim |
dim3 |
Grid 的维度(Block 数) |
3.2 一维索引计算
最常用的模式——将线程映射到一维数组:
1 | // 全局线程 ID = Block 编号 × Block 大小 + Block 内线程编号 |
对于一个 gridDim=4, blockDim=8 的配置,全局索引分布如下:
1 | Block 0: [ 0 1 2 3 4 5 6 7] |
3.3 二维索引计算
处理矩阵时,通常使用 2D Grid 和 2D Block:
1 | int col = blockIdx.x * blockDim.x + threadIdx.x; |
3.4 Grid-stride Loop 模式
当数据量大于线程总数时,使用循环让每个线程处理多个元素:
1 | __global__ void processLargeArray(float* data, int N) { |
💡 提示:Grid-stride loop 是 CUDA 编程的最佳实践——它既能正确处理任意大小的数据,又能让你自由选择 Grid 大小来优化性能。
4. Kernel 函数与启动语法
4.1 Kernel 定义规则
1 | __global__ void myKernel(float* input, float* output, int N) { |
Kernel 函数的约束:
- 返回类型必须是
void - 不能使用可变参数(variadic)
- 不能是类的虚函数
- 不能递归(Compute Capability < 2.0,现代 GPU 均已支持)
- 参数通过值传递,指针必须指向设备内存
4.2 启动语法 <<<...>>>
1 | kernel<<<gridDim, blockDim, sharedMemBytes, stream>>>(args...); |
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
gridDim |
dim3 或 int |
是 | Grid 中 Block 的数量 |
blockDim |
dim3 或 int |
是 | 每个 Block 中线程的数量 |
sharedMemBytes |
size_t |
否 | 动态共享内存大小(默认 0) |
stream |
cudaStream_t |
否 | 执行流(默认 0,即默认流) |
4.3 计算启动配置
给定数据大小 N 和 Block 大小,计算需要多少个 Block:
1 | int N = 1000000; |
📌 关键点:(N + blockSize - 1) / blockSize 是整数除法向上取整的标准写法。当 N 不是 blockSize 整数倍时,最后一个 Block 中会有一些线程”越界”,因此 Kernel 内必须做边界检查 if (idx < N)。
4.4 错误检查
Kernel 启动是异步的,错误不会立即被捕获:
1 | myKernel<<<grid, block>>>(args); |
5. Block 大小选择策略
Block 大小的选择直接影响 GPU 利用率和性能。没有放之四海皆准的最优值,但有一些实用的经验法则。
5.1 基本原则
| ✅ 推荐做法 | ❌ 不推荐做法 | 📝 原因 |
|---|---|---|
| Block 大小为 32 的倍数 | 使用非 32 倍数的大小 | Warp 以 32 线程为单位调度 |
| 128~512 线程/Block | < 64 或 > 1024 | 平衡寄存器压力和并行度 |
| 根据 Kernel 寄存器用量调整 | 一律使用 256 | 寄存器多时需减小 Block |
5.2 为什么必须是 32 的倍数
GPU 的执行以 Warp(32 个连续线程)为最小调度单位。如果 Block 大小不是 32 的倍数,最后一个 Warp 中的部分线程会被浪费:
1 | Block 大小 = 100 → 4 个 Warp,但第 4 个 Warp 中只有 4 个线程活跃 |
5.3 Occupancy API(自动选择)
CUDA 提供 API 来自动计算最优 Block 大小:
1 | int blockSize; |
5.4 实用选择指南
| 场景 | 建议 Block 大小 | 理由 |
|---|---|---|
| 简单逐元素操作 | 256 或 512 | 寄存器少,可以承受大 Block |
| 使用大量共享内存 | 128 或 256 | 共享内存限制了活跃 Block 数 |
| 寄存器用量大的 Kernel | 128 | 减少 Block 大小释放寄存器压力 |
| 需要 Block 内同步 | 256 | 平衡同步开销和并行度 |
6. Warp:硬件调度的基本单位
6.1 什么是 Warp
如果说 Block 是逻辑上的协作单位,那 Warp 就是物理上的执行单位。SM(Streaming Multiprocessor)以 Warp 为粒度调度执行——一个 Warp 包含 32 个连续线程,它们在同一时钟周期内执行同一条指令(SIMT,Single Instruction Multiple Threads)。
6.2 Warp 分歧(Divergence)
当 Warp 内的线程走不同的分支路径时,发生 Warp 分歧——GPU 不得不串行执行两个分支:
1 | // ❌ 容易导致分歧 |
💡 提示:只要同一个 Warp 内的 32 个线程走相同分支,就不会有分歧惩罚。分歧发生在 Warp 内部,不同 Warp 之间走不同分支完全没有代价。
6.3 Warp 级原语
现代 CUDA 提供 Warp 级操作,允许线程在 Warp 内直接交换数据:
1 | // Warp 内广播:将 lane 0 的值广播给整个 Warp |
7. 多维索引与实际应用
7.1 图像处理:2D Grid + 2D Block
1 | __global__ void grayscaleKernel(unsigned char* rgb, unsigned char* gray, |
7.2 矩阵运算:行列映射
1 | __global__ void matrixAdd(float* A, float* B, float* C, |
7.3 批处理:3D Grid
1 | // 第三个维度表示 batch |
8. 占用率与性能
8.1 什么是占用率(Occupancy)
占用率 = SM 上实际活跃的 Warp 数 / SM 理论支持的最大 Warp 数。
$$
\text{Occupancy} = \frac{\text{Active Warps per SM}}{\text{Max Warps per SM}}
$$
占用率越高不一定性能越好,但过低的占用率通常意味着 GPU 资源闲置。
8.2 影响占用率的因素
| 📊 因素 | 📝 影响方式 |
|---|---|
| Block 大小 | Block 太小则活跃 Warp 少 |
| 寄存器用量/线程 | 寄存器多则 SM 能容纳的线程少 |
| 共享内存用量/Block | 共享内存多则 SM 能容纳的 Block 少 |
| SM 的硬件限制 | 不同架构的最大 Block 数和线程数不同 |
8.3 用 Occupancy Calculator 分析
1 | # 编译时输出寄存器和共享内存用量 |
输出示例:
1 | ptxas info : Used 32 registers, 4096 bytes smem, 368 bytes cmem[0] |
然后用 CUDA Occupancy Calculator 或 cudaOccupancyMaxActiveBlocksPerMultiprocessor API 来计算占用率。
📝 总结
CUDA 编程模型的核心要点:
- 三级层次结构:Grid → Block → Thread,逻辑清晰、硬件友好
- 索引计算公式:
globalIdx = blockIdx.x * blockDim.x + threadIdx.x - Grid-stride loop:通用的数据遍历模式,处理任意规模数据
- Block 大小选择:32 的倍数,通常 128~512,可用 Occupancy API 自动选择
- Warp 是执行单位:理解 Warp 分歧,对齐访问模式
- 边界检查:Kernel 内必须检查索引是否越界
掌握编程模型就掌握了 CUDA 并行编程的”地图”——之后学习内存模型、同步原语和优化技巧时,都要回到这个层次结构上来思考。
🎯 自我检验清单
- 能画出 Grid/Block/Thread 的层次结构示意图
- 能根据数据维度选择合适的 Grid 和 Block 维度
- 能手算给定配置下任意线程的全局索引
- 能编写 Grid-stride loop 处理超大数组
- 能使用 Occupancy API 自动计算最优 Block 大小
- 能识别 Warp 分歧并重构代码避免分歧
- 能为矩阵运算和图像处理配置 2D Grid/Block
- 能根据
--ptxas-options=-v的输出分析占用率瓶颈