CUDA开始的GPU编程-第三章
第三章:GPU上的数组
分配一个数组
1 |
|
如 malloc 一样,可以用 cudaMalloc 配合 n * sizeof(int),分配一个大小为 n 的整型数组。这样就会有 n 个连续的 int 数据排列在内存中,而 arr 则是指向其起始地址。然后把 arr 指针传入 kernel,即可在里面用 arr[i] 访问它的第 i 个元素。
因为我们用的统一内存(managed),所以同步以后 CPU 也可以直接读取。
多个线程并行赋值
刚刚的 for 循环是串行的,我们可以把线程数量调为 n,然后用 threadIdx.x 作为 i 索引。这样就实现了,每个线程负责给数组中一个元素的赋值。
1 |
|
小技巧:网格跨步循环(grid-stride loop)
需要注意的是,GPU上虽然可以启动成千上万个线程,但是每个板块上的线程数量是有限的。
在大多数现代CUDA支持的GPU上,blockDim
的最大值通常是:
- 每个维度的最大线程数:1024个线程。
- 每个线程块的总线程数:最多可以有1024个线程(即
blockDim.x
、blockDim.y
、blockDim.z
的乘积最多为1024)。(从这里也可以隐约感觉到CUDA虽然以三个维度组织线程管理,但在硬件实现上并不区分)
因此,如果数组很大的话,我们不能直接在一个板块上启动足够的线程,即<<< g , b>>>
中的b不能太大。
启动多个板块的话,我们就可以通过下面的操作实现网格跨步循环:
1 |
|
无论调用者指定了多少个线程(blockDim),都能自动根据给定的 n 区间循环,不会越界,也不会漏掉几个元素。
这样一个 for 循环非常符合 CPU 上常见的 parallel for 的习惯,又能自动匹配不同的 blockDim,看起来非常方便。
补充:
- 可以通过
cudaDeviceProp
结构中的maxThreadsPerBlock
、maxThreadsDim
来查询具体的硬件限制。
1 | cppCopy codecudaDeviceProp prop; |
- 这些限制是为了保证GPU的硬件能够高效地调度和执行线程。
从线程到板块
核函数内部,用之前说到的 blockDim.x + blockIdx.x + threadIdx.x 来获取线程在整个网格中编号。
外部调用者,则是根据不同的 n 决定板块的数量(gridDim),而每个板块具有的线程数量(blockDim)则是固定的 128。
因此,我们可以用 n / 128 作为 gridDim,这样总的线程数刚好的 n,实现了每个线程负责处理一个元素。
1 |
|
边角料难题
但这样的话,n 只能是的 128 的整数倍,如果不是就会漏掉最后几个元素。
主要是 C 语言的整数除法 n / nthreads,它是向下取整的,比如 7 / 4 = 1。
比如 n 为 65535,那么最后 127 个元素是没有赋值的。
1 |
|
解决边角料难题
解决方法就是:采用向上取整的除法。
可是 C 语言好像没有向上整除的除法这个运算符?没关系,用这个式子即可:
1 | (n + nthreads - 1) / nthreads |
例如:(7 + 3) / 4 = 2,(8 + 3 / 4) = 2。
1 |
|
由于向上取整,这样会多出来一些线程,因此要在 kernel 内判断当前 i 是否超过了 n,如果超过就要提前退出,防止越界。
网格跨步循环:应用于线程与板块一起的情况
解决边角料问题的另一种方法是完整的跨步循环操作:
1 |
|
网格跨步循环实际上本来是这样,利用扁平化的线程数量和线程编号实现动态大小。
同样,无论调用者指定每个板块多少线程(blockDim),总共多少板块(gridDim)。都能自动根据给定的 n 区间循环,不会越界,也不会漏掉几个元素。
这样一个 for 循环非常符合 CPU 上常见的 parallel for 的习惯,又能自动匹配不同的 blockDim 和 gridDim,看起来非常方便。
上面的核函数可以这样拆开,会方便理解:
1 | __global__ void kernel(int *arr, int n) { |
举例解释:
假设我们有以下配置:
数组
arr
的大小为n = 65536
,即包含 65536 个元素。使用
1
kernel<<<32, 128>>>(arr, n);
启动 CUDA 核函数。
gridDim.x = 32
,表示线程网格中有 32 个线程块(blocks)。blockDim.x = 128
,表示每个线程块中有 128 个线程。- 因此,总线程数为
32 * 128 = 4096
个线程。
每个线程都将处理一个不同的数组元素,且使用跨步访问(stride)来分配任务。
- 线程计算公式
每个线程的索引 i
由以下公式计算:
1 | i = blockDim.x * blockIdx.x + threadIdx.x; |
blockIdx.x
是线程块的索引。threadIdx.x
是线程块内的线程索引。blockDim.x
是每个线程块的线程数。
例如:
blockIdx.x = 0
表示第一个线程块。threadIdx.x = 0
表示第一个线程。- 因此,
i = 128 * 0 + 0 = 0
,第一个线程处理索引i = 0
- 跨步访问(stride)
跨步访问是通过增加 i += stride
来实现的,其中 stride
是线程网格中所有线程的总数。对于本例:
1 | int stride = gridDim.x * blockDim.x; // 32 * 128 = 4096 |
这里的 stride
计算为 4096
,表示每个线程在数组中跨步的大小。也就是说,每个线程处理的数据并不是相邻的,而是间隔 4096 个元素。
- 线程的工作范围
每个线程的工作由一个循环实现,使用跨步访问来遍历数组:
1 | for (int i = blockDim.x * blockIdx.x + threadIdx.x; i < n; i += stride) { |
i = blockDim.x * blockIdx.x + threadIdx.x
是线程的起始索引。i += stride
确保线程按步长4096
遍历数组。
- 每个线程块的处理
假设我们有 32 个线程块,每个线程块有 128 个线程。那么对于每个线程块和线程的组合,线程索引 i
会依次进行计算。
第一个线程块(
blockIdx.x = 0
):- 线程
threadIdx.x = 0
:i = 128 * 0 + 0 = 0
,处理arr[0]
。 - 线程
threadIdx.x = 1
:i = 128 * 0 + 1 = 1
,处理arr[1]
。 - 线程
threadIdx.x = 127
:i = 128 * 0 + 127 = 127
,处理arr[127]
。
- 线程
第二个线程块(
blockIdx.x = 1
):- 线程
threadIdx.x = 0
:i = 128 * 1 + 0 = 128
,处理arr[128]
。 - 线程
threadIdx.x = 127
:i = 128 * 1 + 127 = 255
,处理arr[255]
。
- 线程
第32个线程块(
blockIdx.x = 31
):- 线程
threadIdx.x = 0
:i = 128 * 31 + 0 = 3968
,处理arr[3968]
。 - 线程
threadIdx.x = 127
:i = 128 * 31 + 127 = 128
,处理arr[4095]
。
- 线程
这只是第一轮循环的内容,每次循环上面的每个线程都会跳过步长(4096)个元素。最终循环16轮
- 分析第16轮的情况:
- 第一个线程块(
blockIdx.x = 0
):- 线程
threadIdx.x = 0
*- 索引为
i = 0 + 15 * 4096 = 61440
。
- 索引为
- 线程
这里我们计算一下:65535 - 61440 = 4095
可以看出我们确实不需要再跳一次4096了
- 第32个线程块(
blockIdx.x = 31
):- 线程
threadIdx.x = 127
- 索引为
i = (128 * 31 + 127) + 15 * 4096 = 65535
- 索引为
- 线程
终于,我们看到第16轮循环时,最后一个线程块的最后一个线程刚好访问到第65535个元素!