CUDA入门(七):并行模式:前缀和

背景

前缀和(prefix sum)也叫扫描(scan),闭扫描(inclusive scan)操作对 n 元数组[x0, x1, x2, ..., xn-1] 进行二元运算#,返回输出数组 [x0, (x0#x1),..., (x0#x1#x2# ...# xn-1)]。开扫描(exclusive scan) 与闭扫描类似,返回数组 [0, x0, (x0#x1),..., (x0#x1#x2# ...# xn-2)]。将闭扫描输出数组全部右移一位,第 0 个数组赋 0 即得到开扫描的输出数组。

闭扫描算法的串行实现:

void sequential_scan(float *x, float *y, int Max_i){
    y[0] = x[0];
    for(int i=1;i < Max_i;i++){
        y[i] = y[i-1]+x[i];
    }
}

也就是一个简单的动态规划。

简单并行扫描

下面是通过归约树实现的原地扫描算法:

  • 在共享内存中声明数组XY,并将长度为 n 的 x 数组中的值赋给 XY 数组。
  • 进行迭代,在第 n 次迭代时,后 个数分别加上它前面第 个数。
  • 大于n时,停止迭代。

在这里插入图片描述
上面的算法处理元素的数量不能超过一个 block 的线程数。

代码:

#include<cuda.h>
#include<stdio.h>
#include<stdlib.h>
#define SECTION_SIZE 256
__global__ void work_inefficient_scan_kernel(float *X, int input_size){
    __shared__ float XY[SECTION_SIZE];
    int i = blockIdx.x*blockDim.x + threadIdx.x;
    //将数组加载到共享寄存器。
    if(i < input_size){
        XY[threadIdx.x] = X[i];
    }       
    //进行闭扫描
    for(unsigned int stride = 1;stride <= threadIdx.x;stride*=2){
        __syncthreads();
        XY[threadIdx.x] += XY[threadIdx.x - stride];
    }
    __syncthreads();
    X[i] = XY[threadIdx.x];
}


void test(float *A, int n){
    float *d_A, *d_B;
    int size = n * sizeof(float);
    cudaMalloc(&d_A, size);
    cudaMemcpy(d_A, A, size, cudaMemcpyHostToDevice);
    //用SECTION_SIZE作为block大小启动kernel。
    work_inefficient_scan_kernel<<<ceil((float)n/SECTION_SIZE), SECTION_SIZE>>>(d_A, n);
    cudaMemcpy(A, d_A, size, cudaMemcpyDeviceToHost);
    cudaFree(d_A);
}

int main(int argc, char **argv){
    int n = atoi(argv[1]);  //n 不能大于SECTION_SIZE
    float *A = (float *)malloc(n * sizeof(float));
    for(int i = 0; i < n;++i){
        A[i] = 1.0;
    }
    test(A, n);
    for(int i = 0;i < n; ++i){
        printf("%f ", A[i]);
    }
    free(A);
    return 0;
}

效率

现在我们来分析一下上面 kernel 的工作效率。所有线程将迭代 log(SECTION_SIZE) 步。每次迭代中不需要做加法的线程数量等于 stride ,所以我们可以这样计算算法完成的工作量:
$N \times log_2(N)N-1N-1N-1$|49|129|321|769|1793|4097|9217

元素个数为 1024 时,并行比串行多做了 9 倍的加法。随着 N 的增大,这个系数会继续增长。

高效的并行扫描

这个算法由两部分组成,第一部分是归约求和,第二部分是用倒置的树分发部分和。

把一个长度为 N 的数组归约求和(下图的上半部分),一般需要 (N/2) + (N/4) + ... + 2 + 1 = N - 1 次操作。
在这里插入图片描述
归约求和:

没有控制流分支的版本:

for(unsigned int stride = 1;stride <= blockDim.x;stride *= 2){
    __syncthreads();
    int index = (threadIdx.x + 1)*stride*2 - 1;
    if(index < SECTION_SIZE)
        XY[index] += XY[index - stride];
}

归约后数组中的值如下图的第一行:
在这里插入图片描述
从倒置树可以看出,第一次将 XY[7] 加到 XY[11] 上,第二次分别将 XY[3], XY[7], XY[11] 加到 XY[5] , XY[9], XY[13] 上,第三次除 XY[0] 偶数位元素加前面元素。

可以看出,相加的 stride 从 SECTION_SIZE/4 下降到 1。我们需要将位置为 stride 的倍数 - 1 的 XY 元素相加到相距一个 stride 位置上的元素上。具体代码如下:

    for(unsigned int stride = SECTION_SIZE/4; stride > 0; stride /= 2){
        __syncthreads();
        int index = (threadIdx.x + 1)*stride*2 - 1;
        if(index + stride < SECTION_SIZE)
            XY[index + stride] += XY[index];
    }

无论在归约阶段还是在分发阶段,线程数都没有超过 SECTION_SIZE/2。所以可以启动一个只有SECTION_SIZE/2 个线程的 block。因为一个 block 中可以有 1024 个线程,所以每个扫描部分中最多可以有 2048 个元素。所以一个线程就要加载 2 个元素。
完整Brent_Kung_scan代码:

#include<cuda.h>
#include<stdio.h>
#include<stdlib.h>
#define SECTION_SIZE 2048
__global__ void  Brent_Kung_scan_kernel(float *X, int input_size){
    __shared__ float XY[SECTION_SIZE];

    //将数组加载到共享寄存器,一个线程加载两个元素。
    int i = 2 * blockIdx.x * blockDim.x + threadIdx.x;
    if(i < input_size) XY[threadIdx.x] = X[i];
    else XY[threadIdx.x] = 0.0f;
    if(i + blockDim.x < input_size) XY[threadIdx.x + blockDim.x] = X[i + blockDim.x];
    else XY[threadIdx.x + blockDim.x] = 0.0f;

    //不带控制流的归约求和 
    for(unsigned int stride = 1;stride <= blockDim.x;stride *= 2){
        __syncthreads();
        int index = (threadIdx.x + 1)*stride*2 - 1;
        if(index < SECTION_SIZE)
            XY[index] += XY[index - stride];
    }

    //分发部分和
    for(unsigned int stride = SECTION_SIZE/4; stride > 0; stride /= 2){
        __syncthreads();
        int index = (threadIdx.x + 1)*stride*2 - 1;
        if(index + stride < SECTION_SIZE)
            XY[index + stride] += XY[index];
    }
    __syncthreads();
    if(i < input_size) X[i] = X[threadIdx.x];
    if(i + blockDim.x < input_size) X[i + blockDim.x] = XY[threadIdx.x + blockDim.x];
}

void test(float *A, int n){
    float *d_A;
    int size = n * sizeof(float);
    cudaMalloc(&d_A, size);
    cudaMemcpy(d_A, A, size, cudaMemcpyHostToDevice);
    Brent_Kung_scan_kernel<<<ceil((float)n/(SECTION_SIZE/2)), SECTION_SIZE/2>>>(d_A, n);
    cudaMemcpy(A, d_A, size, cudaMemcpyDeviceToHost);
    cudaFree(d_A);
}

int main(int argc, char **argv){
    int n = atoi(argv[1]);
    float *A = (float *)malloc(n * sizeof(float));
    for(int i = 0; i < n;++i){
        A[i] = 1.0;
    }
    test(A, n);
    for(int i = 0;i < n; ++i){
        printf("%f ", A[i]);
    }
    free(A);
    return 0;
}

对于 N 个元素的数组,操作总数为 (N/2) + (N/4) + ... + 4 + 2 -1 约为 N - 2,所以最终的操作数为。下表比较了不同 N 下的执行的操作数。
|N|16|32|64|128|256|512|1024|
|--|--|--|--|--|--|--|--|
| | 15|31|63|127|255|51|1023
|$2 \times N-3$|29|61|125|253|509|1021|2045|

由于操作数量现在与 N 成正比,而不是与 成正比,所以高效的算法执行的总操作数不会超过串行的两倍。只要有至少两倍的执行单元,并行算法的性能就能比串行更好。

更大长度的并行扫描

上面的算法最多只能处理 2048 个元素,我们需要一种能处理更多元素的并行算法,下面的层级扫描就是其中一种方法。

  • 首先对整个数组使用上一节的 Brent_Kung_scan_kernel 进行处理,每个块内都会进行扫描,扫描后结果数组中仅保留了块内扫描的结果,我们称这些段为扫描块。
  • 将每个块最后一个数保留到长度为 m 的辅助数组 S 中,对 S 进行扫描。
  • 将 S 的前 m - 1 个元素分别加到结果数组的后 m - 1 个扫描块的每个元素中。

在这里插入图片描述

当前的 CUDA 设备每个 block 中有1024个线程,每个 block 最多能处理 2048 个元素,grid x 维中有 65536 个线程块,最多可以处理 134217728 个元素。
完成上图的工作我们设计 3 个 kernel。

  1. 第一个 kernel 使用块的第一个线程,将扫描块的最后一个元素加载到 S 数组上。可以使用 Brent_Kung_scan_kernel 然后在其后面加上:

    __syncthreads();
    if(threadIdx.x == 0)
        S[blockIdx.x] = XY[SECTION_SIZE - 1];
  2. 第二个 kernel 对 S 进行扫描。可以使用 Brent_Kung_scan_kernel。

  3. 第三个 kernel 将 S 回写到原本数组上。

最终代码如下:

#include<cuda.h>
#include<stdio.h>
#include<stdlib.h>
#define SECTION_SIZE 2048
__global__ void  Brent_Kung_scan_kernel_1(float *X, float *S, int input_size){
    __shared__ float XY[SECTION_SIZE];

    //将数组加载到共享寄存器,一个线程加载两个元素。
    int i = 2 * blockIdx.x * blockDim.x + threadIdx.x;
    if(i < input_size) XY[threadIdx.x] = X[i];
    else XY[threadIdx.x] = 0.0f;
    if(i + blockDim.x < input_size) XY[threadIdx.x + blockDim.x] = X[i + blockDim.x];
    else XY[threadIdx.x + blockDim.x] = 0.0f;

    //不带控制流的归约求和 
    for(unsigned int stride = 1;stride <= blockDim.x;stride *= 2){
        __syncthreads();
        int index = (threadIdx.x + 1)*stride*2 - 1;
        if(index < SECTION_SIZE)
            XY[index] += XY[index - stride];
    }

    //分发部分和
    for(unsigned int stride = SECTION_SIZE/4; stride > 0; stride /= 2){
        __syncthreads();
        int index = (threadIdx.x + 1)*stride*2 - 1;
        if(index + stride < SECTION_SIZE)
            XY[index + stride] += XY[index];
    }
    __syncthreads();
    if(i < input_size) X[i] = XY[threadIdx.x];
    if(i + blockDim.x < input_size) X[i + blockDim.x] = XY[threadIdx.x + blockDim.x];

    __syncthreads();
    if(threadIdx.x == 0){
        S[blockIdx.x] = XY[SECTION_SIZE - 1];
    }
}

__global__ void  Brent_Kung_scan_kernel_2(float *X, int input_size){
    __shared__ float XY[SECTION_SIZE];

    //将数组加载到共享寄存器,一个线程加载两个元素。
    int i = 2 * blockIdx.x * blockDim.x + threadIdx.x;
    if(i < input_size) XY[threadIdx.x] = X[i];
    if(i + blockDim.x < input_size) XY[threadIdx.x + blockDim.x] = X[i + blockDim.x];

    //不带控制流的归约求和 
    for(unsigned int stride = 1;stride <= blockDim.x;stride *= 2){
        __syncthreads();
        int index = (threadIdx.x + 1)*stride*2 - 1;
        if(index < SECTION_SIZE)
            XY[index] += XY[index - stride];
    }

    //分发部分和
    for(unsigned int stride = SECTION_SIZE/4; stride > 0; stride /= 2){
        __syncthreads();
        int index = (threadIdx.x + 1)*stride*2 - 1;
        if(index + stride < SECTION_SIZE)
            XY[index + stride] += XY[index];
    }
    __syncthreads();
    if(i < input_size) X[i] = XY[threadIdx.x];
    if(i + blockDim.x < input_size) X[i + blockDim.x] = XY[threadIdx.x + blockDim.x];
}

__global__ void kernel_3(float *X, float *S, int input_size){
    //一个线程处理两个元素。
    int i = 2*blockDim.x*blockIdx.x + threadIdx.x;
    if(blockIdx.x > 0){
        X[i] += S[blockIdx.x - 1];
        X[blockDim.x + i] += S[blockIdx.x - 1];
    }
}

void test(float *A, int n){
    float *d_A, *S;
    int size = n * sizeof(float);
    cudaMalloc(&d_A, size);
    cudaMalloc(&S, ceil((float)n/SECTION_SIZE)*sizeof(float));
    cudaMemcpy(d_A, A, size, cudaMemcpyHostToDevice);
    Brent_Kung_scan_kernel_1<<<ceil((float)n/(SECTION_SIZE)), SECTION_SIZE/2>>>(d_A, S, n);
    Brent_Kung_scan_kernel_2<<<ceil((float)(ceil((float)n/SECTION_SIZE))/(SECTION_SIZE)), SECTION_SIZE/2>>>(S, n);
    kernel_3<<<ceil((float)n/(SECTION_SIZE)), SECTION_SIZE/2>>>(d_A, S, n);
    cudaMemcpy(A, d_A, size, cudaMemcpyDeviceToHost);
    cudaFree(d_A);
}

int main(int argc, char **argv){
    int n = atoi(argv[1]);
    float *A = (float *)malloc(n * sizeof(float));
    for(int i = 0; i < n;++i){
        A[i] = 1.0;
    }
    test(A, n);
    // for(int i = 0;i < n; ++i){
    //     printf("%f ", A[i]);
    // }
    printf("%f ", A[n-1]);
    free(A);
    return 0;
}

运行结果:
由于打印速度太慢,所以直接打印最后一个元素,等于输入长度即为正确。
在这里插入图片描述
长度为一千万的时候计算结果错误。

在这里插入图片描述
查找一下最多能正确计算的长度约为 4125000,这可能跟硬件有关,但具体跟什么有关也不清楚。后面学的多了再来补充一下。

全部评论
佬太强了,什么时候来一期cuda排序滴文章
点赞 回复 分享
发布于 2022-08-28 00:56 广东

相关推荐

1.&nbsp;如何在STM32中实现光传感器的数据采集?2.&nbsp;嵌入式系统中如何实现蓝牙通信?3.&nbsp;在FreeRTOS中如何实现任务优先级调度?4.&nbsp;如何在嵌入式系统中实现GPS数据接收?5.&nbsp;在RT-Thread中如何实现定时器的使用?6.&nbsp;嵌入式系统中如何进行图像识别?7.&nbsp;如何在STM32中实现SPI通信?8.&nbsp;嵌入式系统中如何进行温度监测?9.&nbsp;如何在FreeRTOS中实现事件通知机制?10.&nbsp;在STM32中如何实现直流电机控制?11.&nbsp;嵌入式系统中如何进行语音识别?12.&nbsp;如何在RT-Thread中实现文件系统的使用?13.&nbsp;嵌入式系统中如何实现数据的备份与恢复?14.&nbsp;如何在STM32中实现I2C通信?15.&nbsp;嵌入式系统中如何实现气体检测系统?16.&nbsp;如何在FreeRTOS中实现软件定时器?17.&nbsp;嵌入式系统中如何进行光照强度监测?18.&nbsp;如何在STM32中实现LCD显示屏的驱动?19.&nbsp;嵌入式系统中如何实现数据的无线传输?20.&nbsp;如何在RT-Thread中实现多线程的使用?21.&nbsp;嵌入式系统中如何进行步态识别?22.&nbsp;如何在STM32中实现UART通信?23.&nbsp;嵌入式系统中如何进行多媒体数据处理?24.&nbsp;如何在FreeRTOS中实现信号量的使用?25.&nbsp;嵌入式系统中如何实现人脸识别?26.&nbsp;如何在STM32中实现ADC采样?27.&nbsp;嵌入式系统中如何进行电源管理?28.&nbsp;如何在RT-Thread中实现消息通知机制?29.&nbsp;嵌入式系统中如何实现数据的实时传输?30.&nbsp;如何在STM32中实现Ethernet通信?31.&nbsp;嵌入式系统中如何进行环境监测?32.&nbsp;如何在FreeRTOS中实现任务间通信?33.&nbsp;嵌入式系统中如何实现电机反馈控制?34.&nbsp;如何在STM32中实现数字信号处理?35.&nbsp;嵌入式系统中如何进行系统性能优化?嵌入式C++面经推荐大佬面经&nbsp;&nbsp;链接在下边&nbsp;&nbsp;c++/嵌入式面经专栏-牛客网 https://www.nowcoder.com/creation/manager/columnDetail/MJNwoM
点赞 评论 收藏
分享
2024-12-24 08:58
已编辑
北京邮电大学 C++
事情原委:若干年前的一天,我正在被面试苦恼。第二天就要面试了,脑子里围绕着几个问题明天面试官会问什么问题?C/C++?项目细节拷打?计算机网络?实战情景题?还是…索性开始看面经,我也走了很多弯路,一开始只刷题不看面经,觉得面经这东西又不是固定的,即使多看两个少看两个又有什么区别,反正自己又摸不透面试官。当时呢,每天就是在焦虑-&amp;gt;刷牛客-&amp;gt;焦虑,牛客自己是越刷越焦虑。初见端倪:一开始呢,每天刷着同学们分享的面经,欺骗自己大脑假装努力,实际上自己看一篇面经,知识跳跃太大,有的太简单有的太难,难的呢&nbsp;自己自信心-1&nbsp;简单的自信心+1,一加一减,最后一复盘还是不会,偶然间看到有同学自己总结了java面经,然后呢我自己想,自己也总结一份自己的面经,到时候呢自己也可以当当笔记看看,刚好用牛客就开始记录归纳自己碰到的问题。渐露真相:平平的一日,我把自己的面经总结分享到了牛客平台,因为太晚,随即睡去。第二天一早&nbsp;收到官方的消息,你的文章已登录牛客热点榜,一开始觉得不可思议,随即去查自己的分享文章,赫然在榜。也可能是激励效应&nbsp;第一次在榜给了我一些鼓励,从此不可收拾。愈演愈烈:自那以后,我便开始创建自己的专栏,时至今日&nbsp;已经一年多了,其实专栏更新的比较少了,今年也可能是工作原因,也可能是自己变懒了,工作一忙感觉自己的精力大不如前了,还觉得自己非常年轻,仔细想想当了这么多年的打工人了。水落石出:那么到今年已经一年多了,为了感觉小伙伴们的支持和喜爱,从今天起呢,这个专栏将免费开放了,下方专栏可以查看&nbsp;&nbsp; http://daxprogram.com
点赞 评论 收藏
分享
评论
1
2
分享

创作者周榜

更多
牛客网
牛客企业服务