四、CUDA排序算法实现

目录

[4.1 基数排序](#4.1 基数排序)

[4.1.1 CPU代码](#4.1.1 CPU代码)

[4.1.2 GPU代码](#4.1.2 GPU代码)

[4.2 合并列表](#4.2 合并列表)

[4.2.1 CPU代码](#4.2.1 CPU代码)

[4.2.2 GPU代码](#4.2.2 GPU代码)

[4.2.2.1 并行合并](#4.2.2.1 并行合并)


CUDA C/C++ 中的函数修饰符:

变量声明:

  • __shared__ :是一个关键的限定符,用于声明‌共享内存(Shared Memory)
  • __constant__: 是一个限定符,用于声明存储在‌常量内存‌(Constant Memory)中的变量
  • cudaMalloc :是 CUDA 运行时 API 中用于在 GPU 设备内存(全局内存)中分配内存的函数,类似于 CPU 端的 malloc 函数,但分配的是 GPU 显存,允许程序在 GPU 上进行并行计算
  • __managed__:用于声明统一内存(Unified Memory)变量

函数声明:

  • __global__‌:用于声明 CUDA 内核函数(Kernel),该函数在 GPU 上执行
  • __host__‌:用于声明函数仅在主机(CPU)上运行
  • __device__ : 用于声明在 GPU 设备上执行的函数,且只能从设备端代码(如核函数或其他 __device__ 函数)调用
  • __host__ __device__‌:实现同一函数在主机和设备端的双重编译,可以在主机和设备上都被调用

调用内核:

cpp 复制代码
kernel_function<<<num_blocks, num_threads>>>(param1, param2, ...)

参数num_threads表示内核函数的线程数量。在这个例子中,线程数目即循环迭代的次数。内核调用的下一部分是参数传递。我们可以使用寄存器或者常量内存进行参数传递。如果使用寄存器传参,每个线程用一个寄存器来传递一个参数。

线程块:

num_blocks是内核函数调用中的第一个参数,如果将这个参数从1改成2,就是告诉GPU硬件,将启动之前线程数量的2倍线程。例如:

cpp 复制代码
some_kernel_func<<<2,128>>>(a,b,c)

这将会调用名为some_kernel_func的GPU函数2*128次,每次都是不同的线程。

cpp 复制代码
// 设备端函数,计算平方
__device__ float square(float x) {
    return x * x;
}

// 核函数调用设备函数
__global__ void kernel(float* input, float* output, int n) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < n) {
        output[idx] = square(input[idx]);  // 调用 __device__ 函数
    }
}

线程网格:

cpp 复制代码
dim3 threads_rect(32,4);
dim3 blocks_rect(1,4);

每个线程块的X轴方向上开启了32个线程,Y轴方向上开启了4个线程;在线程网格上,X轴方向上有一个线程块,Y轴方向有4个线程块。之后,我们可以通过以下代码来启动内核:

cpp 复制代码
some_kernel_func<<<blocks_rec,threads_rec>>>(a,b,c)

4.1 基数排序

基数排序通过从最低有效位到最高有效位一一进行比较,对数值排序。对于一个32位的整型数,使用一个基数位,无论数据集有多大,整个排序需要迭代32次。按照比特位依次比较

例子:{122,10,1,2,9}

它们二进制分别为:

122:01111010

10 :00001010

1 :00000001

2 :00000010

9 :00001001

**第一轮:**最低有效位为0的元素放一起,为1的放一起

**第二轮:**比较的有效位往左移动一位

第三轮

第四轮:

第五轮:

第六轮:

第七轮:

第8轮:

...

直到第32轮

4.1.1 CPU代码

为了建立列表,我们需要N+2N个内存单元。我们可以将比特位为0的数从列表头开始存放,比特数为1的从列表尾开始存放。我们使用两个单独的列表。

cpp 复制代码
__host__ void cpu_sort(u32 * const data, const u32 num_elements)
{
    static u32 cpu_tmp_0[NUM_ELEME];
    static u32 cpu_tmp_1[NUM_ELEME];
    for (u32 bit=0;bit<32;bit++)
    {
        u32 base_cnt_0=0;
        u32 base_cnt_1=0;
        for(u32 i=0;i<num_elements;i++)
        {
            const u32 d = data[i];
            const u32 bit_mask=(1<<bit);
            if ((d&bit_mask)<0)
            {
                cpu_tmp_1[base_cnt_1]=d;
                base_cnt_1++;

            }
            else
            {
                cpu_tmp_0[base_cnt_0]=d;
                base_cnt_0++;
            }
        }  
        for(u32 i=0;i<base_cnt_0;i++)
        {
            data[i]=cpu_tmp_0[i];
        }
        for(u32 i=0;i<base_cnt_1;i++)
        {
            data[base_cnt_0+i]=cpu_tmp_1[i];
        }

    } 
}

4.1.2 GPU代码

GPU代码需要考虑多线程。

cpp 复制代码
__device__ void radix_sort(u32 * const sort_tmp, 
                           const u32 num_lists,
                           const u32 num_elements,
                           const u32 tid,
                           u32 * const sort_tmp_0,
                           u32 * const sort_tmp_1)
{
    for (u32 bit=0;bit<32;bit++)
    {
        u32 base_cnt_0=0;
        u32 base_cnt_1=0;
        for (u32 i=0;i<num_elements;i+num_lists)
        {
            const u32 elem = sort_tmp[i+tid];
            const u32 bit_mask = (1<<bit);
            if ((elem&bit_mask)>0)
            {
                sort_tmp_1[base_cnt_1+tid]=elem;
                base_cnt_1+num_lists;
            }
            else
            {
                sort_tmp_0[base_cnt_0+tid]=elem;
                base_cnt_0+num_lists;
            }
        }
         for(u32 i=0;i<base_cnt_0;i+num_lists)
        {
            sort_tmp[i+tid]=sort_tmp_0[i+tid];
        }
        for(u32 i=0;i<base_cnt_1;i+num_lists)
        {
            sort_tmp[base_cnt_0+i+tid]=sort_tmp_1[i+tid];
        }
    }
__syncthreads();
}
    

此时,GPU内核是以一个设备函数的形式编写的。设备函数是只能被GPU内核调用的函数。它相当于C语言函数声明之前添加一个"static",或者C++中的"private"。

该GPU版本的代码通过将num_lists个线程产生num_lists个独立的排好序的列表。 在代码中的内循环有变化,串行代码,每次循环+1,在并行代码中,每次循环增加num_lists。这个数表示基数排序所产生的独立列表的数目,它等于内核函数每个每个线程块启动的线程数。为了避免存储冲突,它的理想值应该是线程束的大小32。

优化之后的代码如下:

cpp 复制代码
__device__ void radix_sort(u32 * const sort_tmp, 
                           const u32 num_lists,
                           const u32 num_elements,
                           const u32 tid,
                           u32 * const sort_tmp_0,
                           u32 * const sort_tmp_1)
{
    for (u32 bit=0;bit<32;bit++)
    {
        u32 base_cnt_0=0;
        u32 base_cnt_1=0;
        for (u32 i=0;i<num_elements;i+num_lists)
        {
            const u32 elem = sort_tmp[i+tid];
            const u32 bit_mask = (1<<bit);
            if ((elem&bit_mask)>0)
            {
                sort_tmp_1[base_cnt_1+tid]=elem;
                base_cnt_1+=num_lists;
            }
            else
            {
                sort_tmp_0[base_cnt_0+tid]=elem;
                base_cnt_0+=num_lists;
            }
        }
         for(u32 i=0;i<base_cnt_0;i+num_lists)
        {
            sort_tmp[i+tid]=sort_tmp_0[i+tid];
        }
        for(u32 i=0;i<base_cnt_1;i+num_lists)
        {
            sort_tmp[base_cnt_0+i+tid]=sort_tmp_1[i+tid];
        }
    }
__syncthreads();
}
    

不需要将0列表和1列表分开 ,0列表可以通过重复利用原始列表空间进行创建。掩码实际上是跟bit的单次迭代相关的常量,它是伴随循环索引i的一个常量,因此可以将它移到循环的外面,以下是 稍作优化后的代码:

cpp 复制代码
__device__ void radix_sort(u32 * const sort_tmp, 
                           const u32 num_lists,
                           const u32 num_elements,
                           const u32 tid,
                           u32 * const sort_tmp_1)
{
    for (u32 bit=0;bit<32;bit++)
    {
        const u32 bit_mask = (1<<bit);
        u32 base_cnt_0=0;
        u32 base_cnt_1=0;
        for (u32 i=0;i<num_elements;i+num_lists)
        {
            const u32 elem = sort_tmp[i+tid];
            
            if ((elem&bit_mask)>0)
            {
                sort_tmp_1[base_cnt_1+tid]=elem;
                base_cnt_1+=num_lists;
            }
            else
            {
                sort_tmp[base_cnt_0+tid]=elem;
                base_cnt_0+=num_lists;
            }
        }
        for(u32 i=0;i<base_cnt_1;i+num_lists)
        {
            sort_tmp[base_cnt_0+i+tid]=sort_tmp_1[i+tid];
        }
    }
__syncthreads();
}
    

4.2 合并列表

合并排好序的列表是并行编程中一个比较常用的方法。

4.2.1 CPU代码

假定需要从num_lists个列表中选取数据,我们需要跟踪当前在各个列表中的位置,用list_indexes数组来表示 。 由于数组的数量可能很小,我们使用栈,将数据声明为本地变量,但是对于GPU内核而言,这是一个不好的选择,因为根据GPU的不同,栈可能分配到缓慢的全局内存上,而共享内存可能是GPU上的最优选择。

首先,将索引值全部设置为0,然后对所有元素进行迭代,使用find_min函数得到结果值划分到结果中。

find_min函数从num_lists个数值中找到最小的那一个数。对每一个列表都用到了一个索引进行维护。如果函数找到一个值比当前min_val小,将min_val更新为新找到的值。扫描完所有的列表,最小值对应的列表索引加一,并返回得到的最小值。

cpp 复制代码
void merge_array(const u32 * const src_array,
                 u32 * const dest_array,
                 const u32 num_lists,
                 const num_elements)
{
    const u32 num_elements_per_list = (num_elements/num_lists);
    u32 list_indexes[MAX_NUM_LISTS];
    for(u32 list=0;list<num_lists;list++)
    {
        list_indexes[list]=0;
    }
    for(u32 i=0;i<num_elements;i++)
    {
        dest_array[i]=find_min(srs_array,
                                list_indexes,
                                num_lists,
                                num_elements_per_list);
    }
}

u32 find_min(const u32 * const src_array,
            u32 * const list_indexes,
            const u32 num_lists,
            const u32 num_elements_per_list)
{
    u32 min_val = 0xFFFFFFFF;
    u32 min_idx = 0;
    for (u32 i=0;i<num_lists;i++)
    {
        if(list_indexes[i]<num_elements_per_list)
        {
            const u32 src_index=i+(list_indexes[i]*num_lists];
            const u32 data = src_data[src_index];
            if (data<min_val)
            {
                min_val = data;
                min_idx = i;
            }
        }
    }
    list_indexes[min_idx]++;
    return min_val;

}

4.2.2 GPU代码

顶层函数如下:

cpp 复制代码
__global__ void gpu_sort_array_array(u32 * const data,
                                     const u32 num_lists,
                                     const u32 num_elements)
{
    const u32 tid = (blockIdx.x*blockDim.x)+threadIdx.x;
    __shared__ u32 sort_tmp[NUM_ELEM];
    __shared__ u32 sort_tmp_1[NUM_ELEM];
    copy_data_to_shared(data,sort_tmp,num_lists,num_elements,tid);
    radix_sort2(sort_tmp,num_lists,num_elements,rid,sort_tmp_1);
    merge_array6(sort_tmp,data,num_lists,num_elements,tid);
}

函数copy_data_to_shared以行的形式将数据从全局内存读入到共享内存。要想程序尽可能的快,就要使用共享内存替换全局内存。以行的形式访问全局内存性能最好,以列的形式访问将产生离散的内存模式,除非每个线程都访问 同一列,且所有的地址都是相邻的。

cpp 复制代码
__device__ void copy_data_to_shared(const u32 * data,
                                    u32 * sort_tmp,
                                    const u32 num_lists,
                                    const u32 num_elements,
                                    const u32 tid)
{
    for (u32 i=0;i<num_elements;i+=num_list)
    {
        sort_tmp[i+tid]=data[i+tid];
    }
__syncthreads();
}

当编译程序时,在nvcc编译器选项中选择添加 -v 标志,编译器将打印出一条不相关的信息,用于说明创建了一个栈帧。

当函数调用一个子函数,并传入参数时,这些参数必须以某种方式提供给被调用的函数,比如执行以下的函数调用:

cpp 复制代码
        dest_array[i]=find_min(srs_array,
                                list_indexes,
                                num_lists,
                                num_elements_per_list);

此时有两种方式可以采用,一种是通过寄存器传递需要的值,另一种就是创建一个叫栈帧对的内存区。大多数现代处理器有一个很大的寄存器组,对于一层调用而言一般是足够的,老式架构的处理器会用到栈帧,将参数值压入到栈中,被调用的函数值从栈中弹出

cpp 复制代码
__device__ void merge_array1(const u32 * const src_array,
                            u32 * const dest_array,
                            const u32 num_lists,
                            const u32 num_elements,
                            const u32 tid)
{
    __shared__ u32 list_indexes[MAX_NUM_LISTS];
    list_indexes[tid]=0;
    __syncthreads();
    if (tid==0)  //单个线程跑
    {
        const u32 num_elements_per_list = (num_elements/num_lists);
        for (u32 i=0;i<num_elements;i++)
        {
            u32 min_val=0xFFFFFFFF;
            u32 min_idx=0;
            for(u32 list=0;list<num_lists;list++)
            {
                if(list_indexes[list]<num_elements_per_list)
                {
                    const u32 src_idx=list+(list_indexes[list]*num_lists);
                    const u32 data = src_data[src_idx];
                    if (data<=min_val])
                    {
                        min_val = data;
                        min_idx = list;
                    }
                }
            }
            list_indexes[min_idx]++;
            dest_array[i]=min_val;
        }
    }
}

merge_array1函数将原先的merge_array函数与find_min函数合并起来。重新编译将不再产生栈帧。

4.2.2.1 并行合并

为了获取更好的性能,只用一个线程进行合并是不够的。但是因为是合并到同一个列表,使用多个线程会引入问题。线程必须以某种方式进行合作,这使得合并变得更加复杂。

cpp 复制代码
__device__ void merge_array6(const u32 * const src_array,
                            u32 * const dest_array,
                            const u32 num_lists,
                            const u32 num_elements,
                            const u32 tid)
{
    const u32 num_elements_per_list = (num_elements/num_lists);
    __shared__ u32 list_indexes[MAX_NUM_LISTS];
    list_indexes[tid]=0;
    __syncthreads();
    for (u32 i=0;i<num_elements;i++)
    {
        __shared__ u32 min_val;
        __shared__ u32 u32 min_tid;
        if(list_indexes[tid]<num_elements_per_list)
        {
            const u32 src_idx=tid+(list_indexes[tid]*num_lists);
            data = src_data[src_idx];
        }
        else
        {
            data = 0xFFFFFFFF;
        }
        if (tid==0) 
        {
            min_val = 0xFFFFFFFF;
            min_tid 0xFFFFFFFF;
        }
        __syncthreads();
        atomicMin(&min_val,data);
        __syncthreads();
        if (min_val==data)
        {
            atomicMin(&min_tid,tid);
        }
        __syncthreads();
        if (tid==min_tid) //只用了一个线程将结果写入到输出
        {
            list_indexes[tid]++;
            dest_array[i]=data;
        }
    }
}

这个版本的代码使用了num_lists个线程进行合并操作,但是只用了一个线程一次将结果写入到输出数据列表。使用多个线程比较出最小的值,然后只用一个线程将结果写入输出。在函数中使用了atomicMin函数,每个线程从列表中获取的数据作为输入参数,调用atomicMin,取代了原先的单个线程访问列表中的所有元素找到最小值。

相关推荐
以卿a1 小时前
C++(继承)
开发语言·c++·算法
I_LPL1 小时前
day22 代码随想录算法训练营 回溯专题1
算法·回溯算法·求职面试·组合问题
金融RPA机器人丨实在智能2 小时前
2026动态规划新风向:实在智能Agent如何以自适应逻辑重构企业效率?
算法·ai·重构·动态规划
elseif1232 小时前
【C++】并查集&家谱树
开发语言·数据结构·c++·算法·图论
偷吃的耗子2 小时前
【CNN算法理解】:卷积神经网络 (CNN) 数值计算与传播机制
人工智能·算法·cnn
遨游xyz2 小时前
排序-快速排序
开发语言·python·排序算法
徐小夕@趣谈前端2 小时前
Web文档的“Office时刻“:jitword共建版2.0发布!让浏览器变成本地生产力
前端·数据结构·vue.js·算法·开源·编辑器·es6
问好眼3 小时前
【信息学奥赛一本通】1275:【例9.19】乘积最大
c++·算法·动态规划·信息学奥赛
Daydream.V3 小时前
逻辑回归实例问题解决(LogisticRegression)
算法·机器学习·逻辑回归