CUDA C++ 高效入门第四章 -- CUDA 同步技术之线程块同步

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() 时,才会继续往下执行。先到的线程会等待后到的线程。

  1. 在屏障之前,每个线程执行的语句(尤其是对共享/全局内存的写)必须完成,才能让任何线程越过屏障。

即屏障之前的工作全部完成 → 所有线程汇合 → 屏障之后的工作才开始。

(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。

相关推荐
yuniko-n1 小时前
【JUC】四种内存屏障示例
java·1024程序员节
宇明一不急1 小时前
numa拓扑感知调度
1024程序员节
Stitch .1 小时前
投稿正文_图床版
1024程序员节
stereohomology1 小时前
更新字帖发现zcode的功能很丰富:dynamic workflows的表现
1024程序员节
张小姐的猫1 小时前
【MySQL】内置函数
1024程序员节
乔旭伟1 小时前
Vben3 中后台表格封装实战:useVbenVxeGrid 的全局收敛与七个坑
前端·anti-design-vue·1024程序员节
Liekkas Kono1 小时前
20261017-20261024 周刊
1024程序员节·swhl
Daneas1 小时前
3060 12G显卡+16G内存可以跑的Z-image效果与SD对比
ai作画·1024程序员节
我不会插花弄玉1 小时前
12. 视图【由浅入深-MySQL】
1024程序员节