Bootstrap

openMP和Cuda对加密程序进行并行化加速

CPU多线程加速

OpenMP介绍

  OpenMP (Open Multi-Processing) 是一个用于多线程编程的应用程序接口(API),广泛用于C、C++和Fortran语言中。它主要用于共享内存并行计算环境中,即在一个单独的进程中多个线程共享内存空间,以便更高效地执行并行计算任务。该接口具有以下特性:

  • (a) 简单性和易用性
    OpenMP通过编译器指令(#pragma)的形式提供并行性,使得程序员可以轻松地将并行性添加到他们的代码中。这些指令告诉编译器哪些代码块应该并行执行。
  • (b) 可移植性
    OpenMP提供了一个跨多种处理器架构的标准,这意味着使用OpenMP编写的程序可以在不同的硬件上运行,而无需修改代码。
  • © 动态线程管理
    OpenMP能够根据运行时环境的可用资源动态调整线程的数量。程序员可以设置线程数的上限,但实际运行时系统会根据负载和硬件能力调整实际线程数。
  • (d) 循环并行化
    在许多应用程序中,最大的性能提升来自于循环的并行化。OpenMP提供了简单的方式来并行化循环,通常只需要添加一条指令。
  • (e) 任务并行性
    除了循环并行性外,OpenMP还支持更一般的任务并行性,允许程序将不同的代码块分配给不同的线程。
  • (f) 内存模型
    OpenMP使用共享内存模型,这意味着所有线程都可以访问相同的内存空间。这简化了数据共享,但也要求程序员小心处理数据同步和避免竞争条件。

  这些特性使得开发者可以通过OpenMP相对容易地开发出能够充分利用多核和多处理器硬件的高效程序,因为科学计算、工程模拟、计算机图形学等领域的应用程序通常可以从并行处理中受益,因此OpenMP在这些领域非常流行。

设计思想

  如介绍的(a)(d)特性,OpenMP提供了简单的方式来并行化循环,通常只需要添加一条#pragma指令,也就是#pragma omp parallel for num_threads(nthreads)语句即可。
  #pragma omp是OpenMP指令的开始,#pragma 是编译器指令的一种,通常用来提供额外的信息给编译器,在这里它会告诉编译器接下来的代码块将使用OpenMP框架进行并行处理。parallel关键字用于启动并行区域。当编译器遇到这个指令时,它会创建一组线程,这些线程可以并行执行后续的代码块。当与 parallel 关键字结合时,for 指令告诉编译器循环应该并行执行,这样循环的迭代会在创建的多个线程之间分配,以实现并行处理。而num_threads(nthreads)指定了在并行区域中要创建的线程数。nthreads 是一个常量,它指定了线程的数量,如果不指定 num_threads,OpenMP将会根据环境来决定线程数,如可用的处理器核心数量。
  OpenMP设计用于共享内存多处理器(通常是CPU)上的并行编程,在C++中使用OpenMP的注释时,它默认是调用CPU的多线程,编译器会生成可以在多个CPU核心上并行运行的代码。

编码实现

加速的代码首先需要让编辑器开启OpenMP支持,在Visual Studio中,开启步骤如下:
(1)右键项目名称,打开属性设置
图6.1 设置OpenMP支持步骤一

(2)依次展开 配置属性 → C/C++ → 语言,将OpenMP支持设置为“是”。
在这里插入图片描述
  然后对加密解密的循环部分添加上OpenMP指令语句,部分代码如下:

void henonEncrypt(MYRGBQUAD** pixel, vector<res2D> henonSequence, int height, int width) {
    #pragma omp parallel for num_threads(nthreads)  // 添加上OpenMP指令语句开启多线程加速
    for (unsigned int i = 0; i < henonSequence.size(); i++) {
        int r1 = getIndex(height, henon_min_x, henon_max_x, henonSequence[i].x);
        int r2 = getIndex(height, henon_min_y, henon_max_y, henonSequence[i].y);
        int c1 = getIndex(width, henon_min_x, henon_max_x, henonSequence[i].x);
        int c2 = getIndex(width, henon_min_y, henon_max_y, henonSequence[i].y);
        exchange(r1, r2, pixel, width, "row");
        exchange(c1, c2, pixel, height, "col");
    }
}

  对加速效果进行分析,使用clock_t对加速前后的加密解密过程计时,然后计算加速比,即未加速耗时与加速耗时的比值,代码如下:

    clock_t startTime, endTime;
    cout << "---OpenMP" << nthreads << "线程加速---" << endl;
    startTime = clock();
    encrypt_omp();
    endTime = clock();
    double omp_encrypt_use_time = ((double)endTime - (double)startTime) / CLOCKS_PER_SEC;
    cout << "加密用时:" << omp_encrypt_use_time << endl;

    startTime = clock();
    decrypt_omp();
    endTime = clock();
    double omp_decrypt_use_time = ((double)endTime - (double)startTime) / CLOCKS_PER_SEC;
    cout << "解密用时:" << omp_decrypt_use_time << endl;
 
    cout << "---不使用加速----" << endl;
    startTime = clock();
    encrypt();
    endTime = clock();
    double encrypt_use_time = ((double)endTime - (double)startTime) / CLOCKS_PER_SEC;
    cout << "加密用时:" << encrypt_use_time << endl;

    startTime = clock();
    decrypt();
    endTime = clock();
    double decrypt_use_time = ((double)endTime - (double)startTime) / CLOCKS_PER_SEC;
    cout << "解密用时:" << decrypt_use_time << endl;

    cout << endl;
    cout << "加速比为:" << 
        (encrypt_use_time + decrypt_use_time) / 
        (omp_encrypt_use_time + omp_decrypt_use_time) << endl;

结果分析

  对两幅尺寸差距较大的图像进行测试,分别是Lena图像(800KB)和Takina图像(5.27MB),最终执行结果如下(单位:S):
图像12线程加速效果
Takina图像12线程加速效果
  从加速效果可以看出,在图像尺寸较小的时候,加速效果并不明显,而且如果进一步缩减图像尺寸,甚至可能会出现反向加速的问题,主要原因就是分配的线程每个线程启动要花费时间,并且需要将线程的计算结果进行合并也需要时间,在计算量不大的时候,线程启动所花费的时间可能比计算本身要费时,因此就会出现反向加速的情况,对于Lena图像而言差不多刚好是使用线程加速和不使用线程加速耗时相同的情况。对于Takina图像,整体加密解密都比较耗时,加速比能够达到2.3,但是远低于期望的加速比12,具体原因可能有以下几个方面。阿姆达尔定律(Amdahl’s Law)定律指出,程序的加速比受限于其串行部分的比例,加密程序中实际上有一部分代码无法并行化,这就会限制总体的加速比。另外就是上面说的线程的创建和管理本身也需要时间和资源,这样就会减少并行收益。除此之外,共享数据也是限制并行化速度的原因之一,当多个线程试图访问和修改共享数据时,可能会发生内存访问冲突,导致缓存一致性问题和内存带宽瓶颈。最后,数据依赖也会导致并行效率的降低,如果代码中存在数据依赖,那么线程之间必须进行同步,这将增加等待时间,同时处理竞争条件所需的锁和同步也会降低性能。上述这些原因导致了即使在计算数据量较大时,加速比也低于期望值的原因,同时这也是线程数过高可能导致加速效果下降的原因,下面的图表对Takina图像不同的并行线程数耗时进行了一个统计,可以看出对于实验电脑来说,当线程数设置为16的时候,对于计算数据较大的时候加速效果最好。
不同线程数耗时折线图

GPU多线程加速

Cuda介绍

  CUDA(Compute Unified Device Architecture)是由NVIDIA公司开发的一个并行计算平台和应用程序接口(API)模型。它允许软件开发者和软件工程师使用NVIDIA GPU(图形处理单元)进行通用处理(即非图形处理),这种技术被称为GPGPU(General-Purpose computing on Graphics Processing Units)。执行线程是可由调度程序独立管理的最小程序指令序列。一般CPU一个核只支持一到两个硬件线程,而GPU往往在硬件层面上就支持同时成百上千个并发线程。CUDA为开发者提供了一系列用于执行计算密集型任务的工具和技术。它通过GPU的大规模并行处理能力,使得能够比传统的CPU更快地处理复杂的计算任务。基于CUDA编程可以利用GPU的并行计算引擎来更加高效地解决比较复杂的计算难题。在CUDA编程中,线程是通过线程网格、线程块、线程束、线程这几个层次进行管理的,CUDA的kernel会划分为网格Grid,每个网格会有多个线程块Block,每个线程块又会有许多个线程Thread,所有的线程会执行相同的核函数,并行执行,这就要求我们在GPU编程中更加高效地管理这些线程,以达到更高的运行效率。
在这里插入图片描述

设计思想

  在Python中kernel函数的实现 需要在核函数之前加上 @cuda.jit标识符,而在c++中,则使用__global__标识符加在函数定义之前。在Python中,核函数的调用使用function_kernel[blocks_per_grid, threads_per_block](x, y, out),这里的blocks_per_grid代表Grid中block在x,y,z三个维度的数量,threads_per_block代表Block中thread在x,y,z三个维度的数量。在c++中则是使用function_kernel<<<blocks_per_grid, hreads_per_block>>> (x, y, out)进行核函数的调用,不过在vs编辑器中第三个左箭头可能会报错,据说是.cu文件中的编译器bug,编译器会按照C++的语法提示错误。
  一般c++中CUDA编程的流程为:分配host内存,并进行数据初始化;分配device内存,并从host将数据拷贝到device上;调用CUDA的核函数在device上完成指定的运算;将device上的运算结果拷贝到host上;释放device和host上分配的内存。
  对于每个线程分配计算任务需要知道当前的线程索引,这就需要对Cuda的线程管理比较熟悉,如前面所介绍,CUDA的kernel会划分为网格Grid,每个网格会有多个线程块Block,每个线程块又会有许多个线程Thread,具体结构图如下。
Cuda管理结构
  在Cuda的C++编程中,可以使用threadIdx.x获取当前线程在block中的索引值,blockIdx.x获取当前线程所在block在grid中的索引值。blockDim.x 获取每个block有多少个线程,因此就能得到线程索引的计算公式:
i n d e x = t h r e a d l d x . x + b l o c k l d x . x ∗ b l o c k D i m . x index=threadldx.x+blockldx.x*blockDim.x index=threadldx.x+blockldx.xblockDim.x
Cuda线程索引计算例子

环境配置

  本环境配置包含了Python和C++两者的配置,在配置好后,执行Python程序时将虚拟环境切换到配置好的环境下即可使用GPU去运行写好的GPU执行代码;而C++则可以通过Visual Studio创建Cuda项目,进行Cuda的GPU编程。
(1)安装Anaconda环境管理器
  Anaconda是一个开源的Python发行版和包管理器,用于数据科学、机器学习和科学计算。它集成了许多常用的Python库、工具和环境管理功能,它能够非常好地管理各种环境,这样就解决了不同项目需要不同的运行环境的问题,尤其是当项目中有细微差距,如第三方包的版本不同时,可以无需重新配置复杂的运行环境,在anaconda powershell prompt环境管理控制台中,可以直接使用conda create -n A --clone B命令来复制A环境,并在B环境中做微调,这对不同项目的部署运行有很大的帮助。
  Anaconda可以直接从官网进行一键式安装,十分便捷。常用的命令包括conda activate A:创建虚拟环境A;conda activate A:激活A环境;conda env list:列出所有的环境;conda list:列出当前环境下安装的所有包的信息。通过conda list可以对环境的所有包进行查看,十分方便,尤其是在项目中遇到因为版本问题的报错,可以非常方便地修改出问题的包。
查看所有的环境
(2)安装CUDA和Cudnn库
  CUDA是一种由NVIDIA提供支持的并行计算平台和编程模型,基于CUDA编写的程序能够充分利用GPU的计算能力,加速各种计算任务,这对于科学计算、数据处理和深度学习等领域尤为重要。许多深度学习框架(如TensorFlow、PyTorch等)都提供了与CUDA的集成,允许利用CUDA加速神经网络的训练和推理过程。Cudnn是NVIDIA专门为深度学习任务而设计的加速库,是基于CUDA架构的深度神经网络库,能够优化深度神经网络的训练和推理过程。
  通过命令nvidia-smi可以查看自己显卡能够支持的CUDA版本,我的电脑最高能够支持CUDA 12.3版本。但是考虑到CUDA、Pytorch、Cudnn版本需要对应,因此不宜安装太高的CUDA版本,避免其他环境的版本不契合,最终我选择安装的版本为为Pytorch 2.1.1,CUDA11.8,Cudnn 8.0。
查看支持的CUDA版本
  CUDA的安装可以通过NVIDIA官网下载安装包进行安装,随后可以在前面安装的Anaconda管理器中使用pip命令或者conda命令安装cudnn。
(3)安装PyTorch库
  PyTorch是一个基于Python的开源机器学习库,专注于深度学习任务。它由Facebook的人工智能研究小组开发并维护,提供了丰富的工具和接口,使得构建深度神经网络模型变得简单而灵活。它提供了强大的GPU支持,PyTorch能够充分利用GPU的加速能力,它提供了针对GPU计算的优化接口,能够使模型训练和推理速度大幅提升。在大多数的深度学习项目中,几乎都会用到PyTorch,因此需要在环境中安装PyTorch才能真正实现使用GPU进行运算。
  由于之前安装了Anaconda,PyTorch的安装可以直接在官网中复制安装的conda命令或pip命令进行一键式安装:conda install pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia。
(4)其他库的安装
  通过Anaconda能够非常方便地安装各种库并进行管理,有时候安装某些库后,可能会安装或者更新某些库,可以使用Anaconda进行管理安装,例如TensorFlow库,TensorFlow是一个由Google开发的开源机器学习框架,用于构建和训练各种机器学习模型。一般来说通过上述步骤配置后应该是不含有TensorFlow库的,因此需要安装,但是需要注意的是,对于GPU环境,需要安装TensorFlow的GPU版本,否则在计算时还是使用CPU资源进行计算。另外如果在安装其他库的时候会级联安装TensorFlow,一般都是CPU版本,需要卸载后重新安装TensorFlow-GPU。
(5)具体使用
  当上述环境配置好后,即可进行调用GPU去计算。在Python项目中可以在项目设置(基于Pychram,一款Python IDE,非本实验主要内容不详述)中,找到Python解释器设置,切换到配置的GPU环境即可。
Python项目切换GPU环境
  而对于C++,则需要使用Visual Studio创建一个Cuda项目,并在项目中的.cu文件结合C++的.h头文件去编写Cuda编程代码。具体步骤如下。
  首先打开Visual Studio,并创建一个新项目,然后选择Cuda进行创建。
创建Cuda项目
  然后就会得到一个Cuda示例项目,项目中包含了一个kernel.cu文件,可以类比为C++项目的cpp的源文件。Cuda代码可以在cu文件中编写,也可以在.h头文件中编辑,但是main函数如果使用了Cuda代码的话,那么main函数只能放在cu文件中,否则会报错。

编码实现

(1)加密解密步骤改造为Cuda
  加密主要有三个循环函数,现在以其中的logistic混沌加密函数作为例子对改造代码进行叙述。
  由于单通道一维logisitic混沌加密会对像素矩阵进行遍历,如果直接对其进行计算,可能存在一些问题。CUDA能有效地处理线性存储的数据,GPU的访存方式是连续访问内存,这样的方式比非线性的方式更加高效,由于2D阵列可能不是连续存储的,这样就会导致访存存在问题,如果按照原顺序访存可能会导致访问顺序出现错误。另外在CUDA中,对设备内存的分配通常是线性的,如果直接在CUDA中使用2D数组,可能需要在每一行之间进行独立的内存分配和拷贝,这会增加复杂性和潜在的性能开销。并且将数据从CPU拷贝到GPU时,一次性拷贝整个连续内存也会比拷贝多个小内存块好。基于上述原因,对于二维的数据,将其改造为Cuda的第一步可以将二维矩阵展开为一维矩阵。
  部分代码:

MYRGBQUAD* flatPixel = new MYRGBQUAD[height * width];
    for (int i = 0; i < height; ++i) {
        for (int j = 0; j < width; ++j) {
            flatPixel[i * width + j] = pixel[i][j];
        }
    }

  随后则是根据分配GPU缓冲区,将数据从主机内存复制到GPU缓冲区,执行核函数和从GPU缓冲区拿回数据的顺序进行改造,需要注意的是最后要将结果复制回原二维矩阵。分配GPU缓冲区可以使用Cuda的cudaMalloc 函数,该函数类似于C语言的malloc函数,它接受两个参数,第一个参数是指向分配的设备内存的指针的地址,第二个参数是要分配的内存的大小,以字节为单位。通过该函数分配GPU缓冲区的代码如下,分别分配混沌序列的数据和图像矩阵的数据:

    MYRGBQUAD* d_pixel;
    double* d_logisticSequence;
    cudaMalloc(&d_pixel, height * width * sizeof(MYRGBQUAD));
    cudaMalloc(&d_logisticSequence, logisticSequence.size() * sizeof(double));

  随后则是将数据从主机内存复制到刚刚分配的GPU缓冲区,通过函数cudaMemcpy进行分配,该函数接受四个参数,分别是目标内存的指针,源内存的指针,要复制的字节数和复制类型,其他三个参数比较好爱好理解,这一步的第四个参数一般来说都是cudaMemcpyHostToDevice,代表将数据从CPU复制到GPU,还有cudaMemcpyDeviceToHost、cudaMemcpyDeviceToDevice和cudaMemcpyHostToHost三个值,分别代表将数据从GPU复制回CPU、在GPU之间进行数据复制、在CPU之间进行数据复制,该步骤将数据复制到刚分配好的GPU缓冲区中,代码如下:

cudaMemcpy(d_pixel, flatPixel, height * width * sizeof(MYRGBQUAD), cudaMemcpyHostToDevice);
cudaMemcpy(d_logisticSequence, logisticSequence.data(), logisticSequence.size() * sizeof(double), cudaMemcpyHostToDevice);

  然后则是执行核函数,可以设置blockDim和gridDim,前者代表了一个block中的线程数,后者定义了整个grid的尺寸。blockDim(x, x):代表每个block包含x^2个线程,以x*x的二维网格组织。为了能覆盖整个图像的宽高,gridDim可以通过宽(width + blockDim.x - 1) / blockDim.x,高同理进行计算,主要是为了计算出需要多少个blocks,确保即使width不能被blockDim.x整除时,也能覆盖整个图像的宽度,使用function_kernel<<<blocks_per_grid, hreads_per_block>>> (x, y, out)执行核函数,需要注意的是核函数和核函数中使用到的其他函数分别要使用__global__和__device__进行定义,代码如下:

dim3 blockDim(16, 16);
dim3 gridDim((width + blockDim.x - 1) / blockDim.x, (height + blockDim.y - 1) / blockDim.y);
logisticEncryptKernel << <gridDim, blockDim >> > (d_pixel, d_logisticSequence, height, width);

  然后则是从GPU缓冲区拿回数据,需要注意的是要在拿回数据的时候需要使用cudaDeviceSynchronize()来对线程进行同步,这个函数等待直到GPU完成其所有当前进行的任务,确保所有的CUDA核心已经完成执行,对于线程同步来说非常重要,代码如下:

cudaDeviceSynchronize();
cudaMemcpy(flatPixel, d_pixel, height * width * sizeof(MYRGBQUAD), cudaMemcpyDeviceToHost);

(2)排序算法改造为Cuda
  在加密解密中,会有一个步骤是生成混沌序列并进行排序得到有序的序列,选择出其中最大值作为随机的初始值以加强算法的随机性,如果不使用GPU,可以采用快排等效率较高的算法,使用Cuda编程可以采用双调排序这种可以并行计算的排序算法。
  对于双调排序,主要是对双调序列进行二分排序。首先将双调序列分为两份,然后两两比较,小的放到第一个序列中,大的放到第二的序列中,然后对两个序列同样分别处理得到四个序列,不断重复直到序列中的元素个数为2,这样排序完之后得到的序列就是有序序列。

双调序列转有序序列过程
  而对于任意长度为2^n的无需序列,可以通过自底向上构建成双调序列,因为对于任意两个数,必然构成双调序列。所以传入两个元素个数为2的序列,第一个序列排为升序,第二个序列降序,然后这两个序列就构成了元素个数为4的双调序列。然后重复此过程得到长度为8的双调序列,不断重复直到得到的序列长度为输入序列长度的双调序列,最后对这个双调序列进行双调排序即可。
无需序列转双调序列过程
  所以排序函数的主要步骤就是以2的倍数遍历输入序列,然后在每个序列内部,两两比较序列中的元素,小的放到第一个序列中,大的放到第二个序列中,然后对这两个序列再比较两个子序列,不断重复直到比较的最小序列为2,比较的次数为log2(n)。同时需要判断这个函数时升序比较还是降序比较,在两两构成双调序列的时候,第一个升序,第二个降序,最终双调序列排序为有序序列的时候为升序。
  相较于上面加密步骤的代码改造为Cuda代码,双调排序的代码较为简便,这里设置的线程数由于是一维序列,可以直接设置线程数和核函数块的数量,前者设置为机器支持的数量,后者设置为序列长度需要的线程数的最少块数,也就是序列长度除CUDA核函数每个块的线程数向上取整,其余部分不再详述,部分代码如下:

// 核函数使用__global__定义
__global__ void sort() {
    // blockIdx.x  当前线程所在块的编号
    // blockDim.x 每个块的线程数
    // threadIdx.x  当前线程块内的编号
    int index = blockIdx.x * blockDim.x + threadIdx.x;  // CUDA线程索引
    int flag = index ^ index_j;
    if (index > sequence_len) {
        // 检查当前线程是否超出数组长度,避免越界
        return;
    }
    if (flag > index) {
        if ((index & index_i) == 0) {  //上升序列
            if (sequence[index] > sequence[flag]) {
                swap(sequence[index], sequence[flag]);
            }
        }
        else {  //下降序列
            …            
        }
    }
}

void sequence_gpu(int sequence_len) {
    // 分配GPU缓冲区存储混沌序列
    cudaMallocHost((void**)&sequence, sizeof(int) * sequence_len);
    // 生成混沌序列
    …
    // 分配GPU缓冲区存储复制数据
    cudaMallocManaged((void**)&copy_sequence, sizeof(int) * sequence_len);
    // 将数据从内存复制到GPU缓冲区
    cudaMemcpy(copy_sequence, sequence, sizeof(int) * sequence_len, cudaMemcpyHostToDevice);

    int thread = 512;  // CUDA核函数每个块的线程数
    int block = (sequence_len + thread) / thread;  // 核函数块的数量
    for (int i = 2; i <= sequence_len; i = i * 2) {
        for (int j = i / 2; j > 0; j = j / 2) {
            sort << <block, thread >> > (copy_sequence, i, j, sequence_len);
           // 等待内核完成后返回
            cudaDeviceSynchronize();
        }
    }
     // 从GPU缓冲区拿回数据
    cudaMemcpy(sequence, copy_sequence, sizeof(int) * sequence_len, cudaMemcpyDeviceToHost);
}

  上面的算法对于排序而言只能执行长度为2^n的序列,不过对于该项目而言已经足够了。对于任意长度的序列处理,我的想法是判断输入的序列长度size,如果size是2的n次方的话继续,如果size不是2的n次方,可以先确定比这个数大的的最小的2的n次方len,然后创建序列长度为len的数组,然后将数组的前len – size个元素填充INT_MIN或者数组的后len – size个元素用INT_MAX填充。然后进行正常的双调排序,最后结果取前size或后size个元素。但是这样的方法只适用于size比较小的情况下,如果size稍微大一些,比如到100万之后,空间复杂度和时间复杂度会增大很多。
  通过浏览一些博客也了解到了更好的解决方法:首先不断的二分,直到每组只剩下一个元素,然后开始归并。双调排序归并时以不大于 n 的最大 2 的幂次方 2k 为界限,把 2k…n 的元素分别与 0…(n-2k) 的元素比较,然后再分别在0…2k 和 2k…n 这两段上分别应用比较网络。由于二分时是把前 n/2 的序列降序排列后 n/2 的序列升序排列的,而 n-2k < n/2,所以前 n-2k 和后 n-2k 个元素都大于中间的元素,当前 n-2k 个元素和后 n-2k 个元素比较时,会把序列中最大的 n-2k 个元素放到最后 n-2k 个位置上,也就是说比较后,2k…n 的元素都比 0…2k 的元素大,这样在分别对这两段用同样的方法归并,最终得到完整的升序序列。这种方式的核心思想就是:任意的正整数都能表示成 2 的幂指数和的形式。
(3)效率对比编码
该部分的编码与CPU的对比较为类似,不再详述。

结果分析

  最终解密解密的加速效果并不好,出现了反向加速的现象:
Lena图像Cuda加速效果
Takina图像Cuda加速效果
  可以看到,无论是小尺寸图片还是大尺寸图片,使用Cuda加速的效果都是负收益,具体原因应该和前面CPU排序中分析的类似,线程的启动和管理也需要花费时间,除此之外,Cuda还需要开辟GPU空间,在CPU和GPU之间拷贝数据,也需要花费时间。不过从小图片到大图片,加速比从0.1提升到了0.3,说明图像尺寸的增大确实能提升加速效果,虽然无法找到非常大的图片,但是可以猜测当图片尺寸增大到了某个值的时候能够正向加速。另外,排序算法也修改为了Cuda代码,因此可以修改排序序列的长度来观察加速情况。
  下面是双调排序的Cuda加速效果:
短序列Cuda加速效果
  可以看出,对于该项目而言,使用Cuda依然是反向加速。不过经过测试,当序列长度来到26万(218)之后的时候使用CUDA并行加速会开始更快,加速比为1.19。最高测试到序列长度为1亿(227)的时候加速效果十分明显,加速比为45。
中序列Cuda加速效果
长序列Cuda加速效果
  下面是对序列长度和加速比绘制的折线图,横坐标是序列长度(2^x),纵坐标为加速比(y)。可以看出,当序列长度增加到了一定的长度后,加速效果会稳步提升序列长度与加速比折线图
  因此可以总结,当计算量不是特别大的时候,使用CPU执行反正更快,数据量略大可以使用CPU的多线程并行加速,而当计算量特别大的时候,则可以选择使用GPU去加速。

;