1. 前言
1. 前言
CUDA 实现数据并行计算的方式是 SIMT,即让不同的数据在不同的线程中按照相同的指令计算,将串行过程并行化。在 CUDA 编程中,合理地组织线程和确定线程块大小、网格大小是实现高效并行计算的关键。同时,建立数据与线程 ID 的一一对应关系,确保每个线程能够正确地访问和处理数据,是实现核函数计算任务的核心。以下将基于 CUDA 线程层次结构,详细讨论如何根据数据的大小确定线程组织方式,并在核函数中建立数据与线程 ID 的对应关系,包括一维、二维和三维数据的示例。
为了适配图形计算任务和提高线程访问效率,CUDA 编程模型将 GPU 线程划分为两个层次结构:线程网格和线程块。线程网格(grid)是线程结构的第一层,线程网格可以划分为很多线程块,一个线程块里面包含多个线程,线程块(block)是线程结构的第二个层次。通过线程网格和线程块的层次结构,可以方便的将一个核函数的线程配置为一维、二维、三维的结构,以适应具体的计算任务。grid 和 block 的配置参数定义为 dim3 类型的内置参数,dim3 是包含三个无符号整数(x,y,z)成员的结构体变量,在定义时,缺省值初始化为 1。通过 grid 和 block 可以灵活地定义 1-dim,2-dim 以及 3-dim 结构,如下图所示,是一个 gird 和 block 均为 2-dim 的线程组织,图中结构水平方向为 x 轴,kernel 在调用时也必须通过执行配置 <<<grid, block>>> 来指定 kernel 所使用的线程数及结构。
核函数配置线程参数的基本思路时,根据实际任务数据的组织方式,先确线程块的大小,然后根据数据大小和线程块的大小,确定线程网格的大小。同时,确定线程块的大小时要考虑 GPU 硬件性能。
2. 确定线程块和网格参数
2.1. 线程块参数确定(blockDim)
线程块大小是每个线程块中线程的数量,通常是一个三维的尺寸(blockDim.x,blockDim.y,blockDim.z)。选择线程块大小时需要考虑以下几点:
- 硬件限制:每个线程块中的线程数量不能超过 1024(对于大多数现代 GPU)。同时,线程块大小最好能够被 32 整除( CUDA 中的线程是以 warp(32 个线程)为单位执行的,这样可以提高执行效率)。
- 数据访问模式:线程块大小应与数据的访问模式相匹配,以减少内存访问的延迟和提高缓存利用率。
通过大量测试,NVIDIA 官方给出的线程块最优配置是 256 个线程,在这样的配置下,既能在一定程度上隐藏访存延迟,同时也能避免 SM 的调度竞争。
2.2. 网格参数确定(gridDim)
网格大小是每个线程网格中线程块的数量,通常是一个三维的尺寸(gridDim.x,gridDim.y,gridDim.z)。网格大小根据数据量和线程块大小计算得出:
- 一维数据:
- 二维数据:
- 三维数据:
其中,是一维数据大小,是二维数据尺寸,是三维数据尺寸。
3. 建立数据与线程 ID 的对应关系
在核函数中,每个线程可以通过内置变量(blockIdx、blockDim、threadIdx)获取自己的唯一 ID,并根据这个 ID 访问数据。其基本思想是,创建和数据或数据分块一样大小的线程结构,根据线程结构的组织方式,确定的线程索引是唯一的,通过这个唯一的索引作为数据的索引,对数据进行操作。以下是具体步骤:
3.1. 一维数据
假设数据存储在数组 data 中,数据量为 size,线程块大小为 blockSize。
- 计算线程索引:
- 边界检查:
- 访问数据:
3.1.1. 3.1.1- 示例代码
__global__ void addOneToArray(float* data, int size)
{
int index = blockIdx.x * blockDim.x + threadIdx.x; // 计算线程索引
if (index < size) // 边界检查
{
data[index] += 1.0f; // 对数组元素加1
}
}
3.2. 二维数据
3.2.1. 3.2.1- 二维数据二维线程结构
假设数据存储在二维数组 image 中,宽度为 width,高度为 height,线程块大小为(blockDim.x,blockDim.y)。
- 计算线程的二维坐标:
- 边界检查:
- 将二维坐标转换为一维索引 (数据按行优先的方式,以一维形式存储在内存中) :
- 访问数据:
3.2.2. 3.2.2- 示例代码
__global__ void addOneToImage(float* image, int width, int height)
{
int x = blockIdx.x * blockDim.x + threadIdx.x; // 计算x坐标
int y = blockIdx.y * blockDim.y + threadIdx.y; // 计算y坐标
int index = y * width + x; // 将二维坐标转换为一维索引
if (x < width && y < height) // 边界检查
{
image[index] += 1.0f; // 对像素值加1
}
}
3.2.3. 3.2.3- 二维数据一维线程结构
假设有一个一维线程数组,线程块大小为 blockDim.x,网格大小为 gridDim.x,数据存储在二维数组 image 中,宽度为 width,高度为 height。
- 计算线程索引:
- 将一维线程索引映射到二维坐标:
- 边界检查:
- 访问数据:
3.2.4. 3.2.4- 示例代码
__global__ void addOneToImage(float* image, int width, int height)
{
int index = blockIdx.x * blockDim.x + threadIdx.x; // 计算线程索引
int x = index % width; // 计算x坐标
int y = index / width; // 计算y坐标
if (x < width && y < height) // 边界检查
{
image[y * width + x] += 1.0f; // 对像素值加1
}
}
3.3. 三维数据
假设数据存储在三维数组 volume 中,宽度为 width,高度为 height,深度为 depth,线程块大小为(blockDim.x,blockDim.y,blockDim.z)。
- 计算线程的三维坐标:
- 边界检查:
- 将三维坐标转换为一维索引:
- 访问数据:
3.3.1. 3.3.1- 示例代码
__global__ void addOneToVolume(float* volume, int width, int height, int depth)
{
int x = blockIdx.x * blockDim.x + threadIdx.x; // 计算x坐标
int y = blockIdx.y * blockDim.y + threadIdx.y; // 计算y坐标
int z = blockIdx.z * blockDim.z + threadIdx.z; // 计算z坐标
int index = z * (width * height) + y * width + x; // 将三维坐标转换为一维索引
if (x < width && y < height && z < depth) // 边界检查
{
volume[index] += 1.0f; // 对体素值加1
}
}
4. 总结
确定合理的线程配置是 CUDA 编程的第一个环节,而建立数据和线程 ID 的映射关系,是实现核函数正确计算的关键,同时,在进行 CUDA 的性能优化时,绕不开数据、线程的映射关系的调整。本文以最基础的数据示例简单讨论了其基本逻辑,后续在不同算子优化中,将逐步对其进行更深入的分析。