CUDA C++ 高效入门第四章 -- CUDA 同步技术之线程块同步
- [1 资料](#1 资料)
- [2 正文](#2 正文)
-
- [2.1 数组归约引入](#2.1 数组归约引入)
- [2.1 CPU 版本的数组归约](#2.1 CPU 版本的数组归约)
- [2.3 使用线程块同步,实现 GPU 版本的数组归约](#2.3 使用线程块同步,实现 GPU 版本的数组归约)
- [3 资料](#3 资料)
1 资料
上一篇文章 CUDA C++ 高效入门第四章 -- CUDA 同步技术 101 ,我们对 CUDA 的同步技术进行了盘点,按照两层并行计算进行分类。本文我们介绍第一层并行计算的第一种同步技术,即线程块内的同步。线程块内的同步主要使用 __syncthreads 函数。
数组归约是 CUDA 编程真正意义上的 Hello World,理解了数组归约,就基本掌握了 CUDA 加速的核心思想,面试必考。我们将使用数组归约,串联整个第一层并行计算的同步技术,包括线程块同步,原子函数,线程束同步和协作组。
本文参考资料如下:
(1)樊哲勇:CUDA编程 基础与实践 第九,十,十一章
(2)cuda-programming-guide 2.2 编写 CUDA SIMT 内核
(3)cuda-programming-guide 2.3 异步执行
本系列博客汇总链接:CUDA C++ 高效入门系列。
2 正文
2.1 数组归约引入
(1)数组归约:把数组中的所有元素,通过某个操作(如加法、求最大值),合并成一个值。本文讲解数组求和。
(2)两种归约方式:
第一,使用 CPU 编程的做法是循环遍历;
第二,使用 GPU 编程的归约 算法 称之为折半归约(binary reduction),具体做法是将数组分为 BLOCK_SIZE 部分,每部分交由一个线程块处理。对于每个 BLOCK_SIZE 子数组,将后半部分的各个元素与前半部分对应的数组元素相加,重复此过程,最后得到的第一个数组元素就是最初的数组中各个元素的和。这个过程要求对同一个线程块的线程进行同步,避免数据竞争。由于不同线程块负责不同的子数组,因此线程块之间不需要进行线程同步。
2.1 CPU 版本的数组归约
(1)我们先实现 CPU 版本的数组归约,在我的机器上平均耗时 205 ms。
创建 05_cuda_memory/reduce1cpu.cu:
cpp
#include <cstdio>
#include "cuda_error.cuh"
// typedef float real;
typedef double real;
const int NUM_REPEATS = 20;
real reduce(const real *x, const int N) {
real sum = 0.0;
for (int i = 0; i < N; ++i) {
sum += x[i];
}
return sum;
}
void timing(const real *x, const int N) {
real sum = 0;
for (int repeat = 0; repeat <= NUM_REPEATS; ++repeat) {
cudaEvent_t start, stop;
CHECK_CUDA_CALL(cudaEventCreate(&start));
CHECK_CUDA_CALL(cudaEventCreate(&stop));
CHECK_CUDA_CALL(cudaEventRecord(start));
cudaEventQuery(start);
sum = reduce(x, N);
CHECK_CUDA_CALL(cudaEventRecord(stop));
CHECK_CUDA_CALL(cudaEventSynchronize(stop));
float elapsed_time;
CHECK_CUDA_CALL(cudaEventElapsedTime(&elapsed_time, start, stop));
printf("Time = %g ms\n", elapsed_time);
CHECK_CUDA_CALL(cudaEventDestroy(start));
CHECK_CUDA_CALL(cudaEventDestroy(stop));
}
printf("sum = %f\n", sum);
}
int main(void) {
const int N = 1e8;
const int M = sizeof(real) * N;
real *x = (real *)malloc(M);
for (int i = 0; i < N; ++i) {
x[i] = 1.23;
}
timing(x, N);
free(x);
}
编译和运行:
txt
ycao@Thinkpad-T14:~/cuda_junior$ ./run_demo.sh 05_cuda_memory/reduce1cpu.cu
--------------------
nvcc -arch=sm_75 -o /tmp/tmp.NJ2SQZz8Zi/run_demo_exec 05_cuda_memory/reduce1cpu.cu
--------------------
Time = 217.82 ms
Time = 210.803 ms
Time = 206.968 ms
Time = 202.586 ms
Time = 202.521 ms
Time = 205.83 ms
Time = 201 ms
Time = 201.494 ms
Time = 203.897 ms
Time = 203.31 ms
Time = 204.67 ms
Time = 205.687 ms
Time = 205.084 ms
Time = 205.947 ms
Time = 205.089 ms
Time = 202.958 ms
Time = 202.957 ms
Time = 202.255 ms
Time = 202.236 ms
Time = 203.18 ms
Time = 201.664 ms
sum = 123000000.110771
2.3 使用线程块同步,实现 GPU 版本的数组归约
(1)这次我们实现 GPU 版本的数据归约,共实现三个版本:不用共享内存,使用静态共享内存和使用动态共享内存。他们都需要使用 __syncthreads() 实现线程块内同步,避免不同线程操作同一数组元素时产生的数据竞争(Data Race)。
(2)__syncthreads 用于同步单个线程块里的线程,只能用于核函数(也包括被核函数调用的设备函数)。__syncthreads 提供的是一个屏障(barrier):
1 当线程块中的所有线程都执行到 __syncthreads() 时,才会继续往下执行。先到的线程会等待后到的线程。
- 在屏障之前,每个线程执行的语句(尤其是对共享/全局内存的写)必须完成,才能让任何线程越过屏障。
即屏障之前的工作全部完成 → 所有线程汇合 → 屏障之后的工作才开始。
(3)创建 05_cuda_memory/reduce2gpu.cu,展示折半归约的实现方式,关注文中的注释部分。
cpp
#include <cstdio>
#include <cstdint>
#include "cuda_error.cuh"
// typedef float real;
typedef double real;
const int NUM_REPEATS = 20;
const int N = 1e8;
const int M = sizeof(real) * N;
const int BLOCK_SIZE = 128;
// 这个核函数只使用全局内存,
// 要求数组长度 N 必须被 BLOCK_SIZE 整除,且 BLOCK_SIZE 为 2 的整数次方,比如这里使用的 BLOCK_SIZE 为 128。
__global__ void reduce_global(real *d_x, real *d_y) {
const int tid = threadIdx.x;
// 这句等价于:real *x = &d_x[blockDim.x * blockIdx.x];
// 他的作用是从整个数组中,取出 BLOCK_SIZE 个元素的首地址,交由一个线程块处理
real *x = d_x + blockDim.x * blockIdx.x;
// blockDim.x >> 1 等价于 blockDim.x / 2; offset >>= 1 等价于 offset /= 2; 使用位运算,是因为效率比较高。
for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1) {
// 这个判断确保随着归约的进行,越来越多的线程空闲下来
if (tid < offset) {
x[tid] += x[tid + offset];
}
// __syncthreads 用于同步单个线程块里的线程,只能用于核函数(也包括被核函数调用的设备函数),
// __syncthreads 提供的是一个屏障(barrier):
// 1. 当线程块中的所有线程都执行到 __syncthreads() 时,才会继续往下执行。先到的线程会等待后到的线程。
// 2. 在屏障之前,每个线程执行的语句(尤其是对共享/全局内存的写)必须完成,才能让任何线程越过屏障。
// 即屏障之前的工作全部完成 → 所有线程汇合 → 屏障之后的工作才开始。 这就是"排队"的精确含义。
// 针对这个例子,该函数确保每次归约涉及的读和写都能顺利走完,不会被线程块的其他线程干扰,避免数据竞争(data race)。
__syncthreads();
}
// 上面的循环结束后,每个 block 所处理的数组元素和就在 x[0] 内,然后再将每个 block 的 x[0] 拷贝出来
// 这个函数归约的结果,是将 N 长的数组,变为 grid_size = N/BLOCK_SIZE 长的数组,而不是直接归约为 1,请注意这一点
// 后续的逻辑会将 d_y 拷贝到主机端,使用循环完成最后的归约。
if (tid == 0) {
d_y[blockIdx.x] = x[0];
}
}
// 这个核函数使用共享内存优化全局内存访问,并优化 N 必须被 BLOCK_SIZE 整除的限制
// 他不要求数组长度 N 必须被 BLOCK_SIZE 整除,但 BLOCK_SIZE 必须为 2 的整数次方,比如这里使用的 BLOCK_SIZE 为 128。
__global__ void reduce_shared(real *d_x, real *d_y) {
const int tid = threadIdx.x;
const int n = blockIdx.x * blockDim.x + tid;
// 共享内存推荐使用 s_* 前缀,
// 下面定义的共享内存数组,会在每一个线程块中保留一份副本,
// 虽然数组变量名一致,但每个线程块的副本都不一样,每个线程块操作自己的副本,互不干涉。
__shared__ real s_y[BLOCK_SIZE];
// 这里是将每个线程块负责的子数组数据,从全局数据总拷贝到当前线程块的共享内存中,从而减少对全局内存的访问
// 由于这里有 n < N 的限制,超出则用 0.0 占位,所以对于这个函数,不要求数组长度 N 必须被 BLOCK_SIZE 整除
// 调用 __syncthreads,确保线程块内的所有线程在操作共享内存之前,数据准备就绪。
s_y[tid] = (n < N) ? d_x[n] : 0.0;
__syncthreads();
// 下面的逻辑同 reduce_global(),唯一的区别是对共享内存里的子数组进行归约,
// 归约完成后,拷贝到 d_y 中,后续的逻辑会将 d_y 拷贝到主机端,使用循环完成最后的归约。
for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1) {
if (tid < offset) {
s_y[tid] += s_y[tid + offset];
}
__syncthreads();
}
if (tid == 0) {
d_y[blockIdx.x] = s_y[0];
}
}
// 上面的 reduce_shared 使用的是静态的共享内存,即编译时指定共享内存的大小
// 这里的核函数使用动态共享内存,当调用核函数时指定共享内存大小。
// 注意:动态共享内存和静态共享内存没有性能的区别
// 这里的核函数也不要求数组长度 N 必须被 BLOCK_SIZE 整除,但 BLOCK_SIZE 必须为 2 的整数次方,比如这里使用的 BLOCK_SIZE 为 128。
__global__ void reduce_dynamic(real *d_x, real *d_y) {
const int tid = threadIdx.x;
const int n = blockIdx.x * blockDim.x + tid;
// 动态共享内存需要加 extern 关键字,使用 [],里面不填共享内存数组的长度
// 调用这个核函数时,由 <<<grid_size, block_size, sizeof(real) * block_size>>> 的第三个参数,指定共享内存大小
extern __shared__ real s_y[];
s_y[tid] = (n < N) ? d_x[n] : 0.0;
__syncthreads();
for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1) {
if (tid < offset) {
s_y[tid] += s_y[tid + offset];
}
__syncthreads();
}
if (tid == 0) {
d_y[blockIdx.x] = s_y[0];
}
}
real reduce(real *d_x, const int method) {
// 等价于: (N % BLOCK_SIZE == 0) ? (N / BLOCK_SIZE) : (N / BLOCK_SIZE + 1);
// 也等价于:(N - 1) / BLOCK_SIZE + 1;
int grid_size = (N + BLOCK_SIZE -1) / BLOCK_SIZE;
const int ymem = sizeof(real) * grid_size;
const int smem = sizeof(real) * BLOCK_SIZE;
real *d_y;
CHECK_CUDA_CALL(cudaMalloc(&d_y, ymem));
real *h_y = (real *)malloc(ymem);
switch(method) {
case 0:
reduce_global<<<grid_size, BLOCK_SIZE>>>(d_x, d_y);
break;
case 1:
reduce_shared<<<grid_size, BLOCK_SIZE>>>(d_x, d_y);
break;
case 2:
reduce_dynamic<<<grid_size, BLOCK_SIZE, smem>>>(d_x, d_y);
break;
default:
printf("Error: wrong method\n");
exit(1);
break;
}
CHECK_CUDA_CALL(cudaMemcpy(h_y, d_y, ymem, cudaMemcpyDeviceToHost));
// 在主机端完成最后的归约
real result = 0.0;
for (int i = 0; i < grid_size; ++i) {
result += h_y[i];
}
free(h_y);
CHECK_CUDA_CALL(cudaFree(d_y));
return result;
}
void timing(real *h_x, real *d_x, const int method) {
real sum = 0;
for (int repeat = 0; repeat <= NUM_REPEATS; ++repeat) {
CHECK_CUDA_CALL(cudaMemcpy(d_x, h_x, M, cudaMemcpyHostToDevice));
cudaEvent_t start, stop;
CHECK_CUDA_CALL(cudaEventCreate(&start));
CHECK_CUDA_CALL(cudaEventCreate(&stop));
CHECK_CUDA_CALL(cudaEventRecord(start));
cudaEventQuery(start);
sum = reduce(d_x, method);
CHECK_CUDA_CALL(cudaEventRecord(stop));
CHECK_CUDA_CALL(cudaEventSynchronize(stop));
float elapsed_time;
CHECK_CUDA_CALL(cudaEventElapsedTime(&elapsed_time, start, stop));
printf("Time = %g ms\n", elapsed_time);
CHECK_CUDA_CALL(cudaEventDestroy(start));
CHECK_CUDA_CALL(cudaEventDestroy(stop));
}
printf("sum = %f\n", sum);
}
// 数组归约
int main(void) {
real *h_x = (real *)malloc(M);
for (int i = 0; i < N; ++i) {
h_x[i] = 1.23;
}
real *d_x;
CHECK_CUDA_CALL(cudaMalloc(&d_x, M));
printf("Using global memory only: \n");
timing(h_x, d_x, 0);
printf("\nUsing static shared memory: \n");
timing(h_x, d_x, 1);
printf("\nUsing dynamic shared memory: \n");
timing(h_x, d_x, 2);
free(h_x);
CHECK_CUDA_CALL(cudaFree(d_x));
return 0;
}
编译和运行:
txt
ycao@Thinkpad-T14:~/cuda_junior$ ./run_demo.sh 05_cuda_memory/reduce2gpu.cu
--------------------
nvcc -arch=sm_75 -o /tmp/tmp.1aqCenrmWC/run_demo_exec 05_cuda_memory/reduce2gpu.cu
--------------------
Using global memory only:
Time = 30.6668 ms
Time = 20.7404 ms
Time = 34.1144 ms
Time = 34.6827 ms
Time = 20.147 ms
Time = 20.1442 ms
Time = 20.2422 ms
Time = 20.1259 ms
Time = 33.7852 ms
Time = 20.156 ms
Time = 34.2279 ms
Time = 20.2106 ms
Time = 20.2445 ms
Time = 20.2997 ms
Time = 20.2154 ms
Time = 20.144 ms
Time = 20.2116 ms
Time = 20.2169 ms
Time = 20.1796 ms
Time = 20.201 ms
Time = 33.9973 ms
sum = 122999999.998770
Using static shared memory:
Time = 22.9785 ms
Time = 23.0035 ms
Time = 23.0175 ms
Time = 23.0818 ms
Time = 22.9341 ms
Time = 22.9862 ms
Time = 23.0139 ms
Time = 22.9846 ms
Time = 22.9976 ms
Time = 22.9751 ms
Time = 22.9347 ms
Time = 23.1091 ms
Time = 22.9712 ms
Time = 22.9986 ms
Time = 22.9451 ms
Time = 22.9819 ms
Time = 22.9973 ms
Time = 22.9846 ms
Time = 23.0688 ms
Time = 23.042 ms
Time = 23.0067 ms
sum = 122999999.998770
Using dynamic shared memory:
Time = 22.9632 ms
Time = 23.0023 ms
Time = 23.0076 ms
Time = 22.9325 ms
Time = 22.9557 ms
Time = 22.978 ms
Time = 22.9958 ms
Time = 23.0199 ms
Time = 22.984 ms
Time = 22.9876 ms
Time = 22.9839 ms
Time = 22.9755 ms
Time = 22.9702 ms
Time = 22.9471 ms
Time = 22.9954 ms
Time = 22.999 ms
Time = 22.9905 ms
Time = 23.0483 ms
Time = 22.9644 ms
Time = 23.0248 ms
Time = 23.1135 ms
sum = 122999999.998770
(4)使用共享内存,可以减少全局内存的访问次数。但在我的机器上,reduce_global 和 reduce_shared 性能上并没有明显的区别,因为使用共享内存本身也有别的开销,比如多一次拷贝,多一些 sync 操作,一般来说,在核函数中对共享内存访问的次数越多,则使用共享内存带来的加速效果越明显。
至于静态和动态共享内存,只是使用方式的不同,并没有性能的区别。
3 资料
GPU 版本的数组归约比 CPU 版本性能提高了十倍,后面我们将继续优化 GPU 版本的数组归约。本文所有代码都托管在本人的 github 上:cuda_junior。