cuda入门——改良第一个 CUDA程序

在上篇中,我们做了一个计算一大堆数字的平方和的程序。不过,我们也提到这个程序的执行效率并不理想。当然,实际上来说,如果只是要做计算平方和的动作,用 CPU 做会比用 GPU 快得多。这是因为平方和的计算并不需要太多运算能力,所以几乎都是被内存带宽所限制。因此,光是把数据复制到显卡内存上的这个动作,所需要的时间,可能已经和直接在 CPU 上进行计算差不多了。

不过,如果进行平方和的计算,只是一个更复杂的计算过程的一部份的话,那么当然在 GPU 上计算还是有它的好处的。而且,如果数据已经在显卡内存上(例如在 GPU 上透过某种算法产生),那么,使用 GPU 进行这样的运算,还是会比较快的。

刚才也提到了,由于这个计算的主要瓶颈是内存带宽,所以,理论上显卡的内存带宽是相当大的。这里我们就来看看,倒底我们的第一个程序,能利用到多少内存带宽。

程序的并行化

我们的第一个程序,并没有利用到任何并行化的功能。整个程序只有一个 thread。在 GeForce 8800GT 上面,在 GPU 上执行的部份(称为 "kernel")大约花费 640M 个频率。GeForce 8800GT 的执行单元的频率是 1.5GHz,因此这表示它花费了约 0.43 秒的时间。1M 个 32 bits 数字的数据量是 4MB,因此,这个程序实际上使用的内存带宽,只有 9.3MB/s 左右!这是非常糟糕的表现。

为什么会有这样差的表现呢?这是因为 GPU 的架构特性所造成的。在 CUDA 中,一般的数据复制到的显卡内存的部份,称为 global memory。这些内存是没有 cache 的,而且,存取 global memory 所需要的时间(即 latency)是非常长的,通常是数百个 cycles。由于我们的程序只有一个 thread,所以每次它读取 global memory 的内容,就要等到实际读取到数据、累加到 sum 之后,才能进行下一步。这就是为什么它的表现会这么的差。

由于 global memory 并没有 cache,所以要避开巨大的 latency 的方法,就是要利用大量的 threads。假设现在有大量的 threads 在同时执行,那么当一个 thread 读取内存,开始等待结果的时候,GPU 就可以立刻切换到下一个 thread,并读取下一个内存位置。因此,理想上当 thread 的数目够多的时候,就可以完全把 global memory 的巨大 latency 隐藏起来了。

要怎么把计算平方和的程序并行化呢?最简单的方法,似乎就是把数字分成若干组,把各组数字分别计算平方和后,最后再把每组的和加总起来就可以了。一开始,我们可以把最后加总的动作,由 CPU 来进行。

首先,在 first_cuda.cu 中,在 #define DATA_SIZE 的后面增加一个 #define,设定 thread 的数目:

#define DATA_SIZE    1048576
#define THREAD_NUM   256

接着,把 kernel 程序改成:

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)
{
    const int tid = threadIdx.x;
    const int size = DATA_SIZE / THREAD_NUM;
    int sum = 0;
    int i;
    clock_t start;
    if(tid == 0) start = clock();
    for(i = tid * size; i < (tid + 1) * size; i++) {
       sum += num[i] * num[i];
    }

result[tid] = sum;
    if(tid == 0) *time = clock() - start;
}

程序里的 threadIdx 是 CUDA 的一个内建的变量,表示目前的 thread 是第几个 thread(由 0 开始计算)。以我们的例子来说,会有 256 个 threads,所以同时会有 256 个 sumOfSquares 函式在执行,但每一个的 threadIdx.x 则分别会是 0 ~ 255。利用这个变量,我们就可以让每一份函式执行时,对整个数据不同的部份计算平方和。另外,我们也让计算时间的动作,只在 thread 0(即 threadIdx.x = 0 的时候)进行。

同样的,由于会有 256 个计算结果,所以原来存放 result 的内存位置也要扩大。把 main 函式中的中间部份改成:

int* gpudata, *result;
    clock_t* time;
    cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
    cudaMalloc((void**) &result, sizeof(int) * THREAD_NUM);
    cudaMalloc((void**) &time, sizeof(clock_t));
    cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
        cudaMemcpyHostToDevice);

sumOfSquares<<<1, THREAD_NUM, 0>>>(gpudata, result, time);

int sum[THREAD_NUM];
    clock_t time_used;
    cudaMemcpy(&sum, result, sizeof(int) * THREAD_NUM,
        cudaMemcpyDeviceToHost);
    cudaMemcpy(&time_used, time, sizeof(clock_t),
        cudaMemcpyDeviceToHost);
    cudaFree(gpudata);
    cudaFree(result);
    cudaFree(time);

可以注意到我们在呼叫 sumOfSquares 函式时,指定 THREAD_NUM 为 thread 的数目。最后,在 CPU 端把计算好的各组数据的平方和进行加总:

int final_sum = 0;
    for(int i = 0; i < THREAD_NUM; i++) {
        final_sum += sum[i];
    }

printf("sum: %d time: %d\n", final_sum, time_used);

final_sum = 0;
    for(int i = 0; i < DATA_SIZE; i++) {
        sum += data[i] * data[i];
    }
    printf("sum (CPU): %d\n", final_sum);

编译后执行,确认结果和原来相同。

这个版本的程序,在 GeForce 8800GT 上执行,只需要约 8.3M cycles,比前一版程序快了 77 倍!这就是透过大量 thread 来隐藏 latency 所带来的效果。

不过,如果计算一下它使用的内存带宽,就会发现其实仍不是很理想,大约只有 723MB/s 而已。这和 GeForce 8800GT 所具有的内存带宽是很大的差距。为什么会这样呢?

内存的存取模式

显卡上的内存是 DRAM,因此最有效率的存取方式,是以连续的方式存取。前面的程序,虽然看起来是连续存取内存位置(每个 thread 对一块连续的数字计算平方和),但是我们要考虑到实际上 thread 的执行方式。前面提过,当一个 thread 在等待内存的数据时,GPU 会切换到下一个 thread。也就是说,实际上执行的顺序是类似

thread 0 -> thread 1 -> thread 2 -> ...

因此,在同一个 thread 中连续存取内存,在实际执行时反而不是连续了。要让实际执行结果是连续的存取,我们应该要让 thread 0 读取第一个数字,thread 1 读取第二个数字…依此类推。所以,我们可以把 kernel 程序改成如下:

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)
{
    const int tid = threadIdx.x;
    int sum = 0;
    int i;
    clock_t start;
    if(tid == 0) start = clock();
    for(i = tid; i < DATA_SIZE; i += THREAD_NUM) {
       sum += num[i] * num[i];
    }

result[tid] = sum;
    if(tid == 0) *time = clock() - start;
}

编译后执行,确认结果相同。

仅仅是这样简单的修改,实际执行的效率就有很大的差别。在 GeForce 8800GT 上,上面的程序执行需要的频率是 2.6M cycles,又比前一版程序快了三倍。不过,这样仍只有 2.3GB/s 的带宽而已。

这是因为我们使用的 thread 数目还是不够多的原因。理论上 256 个 threads 最多只能隐藏 256 cycles 的 latency。但是 GPU 存取 global memory 时的 latency 可能高达 500 cycles 以上。如果增加 thread 数目,就可以看到更好的效率。例如,可以把 THREAD_NUM 改成 512。在 GeForce 8800GT 上,这可以让执行花费的时间减少到 1.95M cycles。有些改进,但是仍不够大。不幸的是,目前 GeForce 8800GT 一个 block 最多只能有 512 个 threads,所以不能再增加了,而且,如果 thread 数目增加太多,那么在 CPU 端要做的最后加总工作也会变多。

更多的并行化

前面提到了 block。在之前介绍呼叫 CUDA 函式时,也有提到 "block 数目" 这个参数。到目前为止,我们都只使用一个 block。究竟 block 是什么呢?

在 CUDA 中,thread 是可以分组的,也就是 block。一个 block 中的 thread,具有一个共享的 shared memory,也可以进行同步工作。不同 block 之间的 thread 则不行。在我们的程序中,其实不太需要进行 thread 的同步动作,因此我们可以使用多个 block 来进一步增加 thread 的数目。

首先,在 #define DATA_SIZE 的地方,改成如下:

#define DATA_SIZE   1048576
#define BLOCK_NUM   32
#define THREAD_NUM   256

这表示我们会建立 32 个 blocks,每个 blocks 有 256 个 threads,总共有 32*256 = 8192 个 threads。

接着,我们把 kernel 部份改成:

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)
{
    const int tid = threadIdx.x;
    const int bid = blockIdx.x;
    int sum = 0;
    int i;
    if(tid == 0) time[bid] = clock();
    for(i = bid * THREAD_NUM + tid; i < DATA_SIZE;
        i += BLOCK_NUM * THREAD_NUM) {
       sum += num[i] * num[i];
    }

result[bid * THREAD_NUM + tid] = sum;
    if(tid == 0) time[bid + BLOCK_NUM] = clock();
}

blockIdx.x 和 threadIdx.x 一样是 CUDA 内建的变量,它表示的是目前的 block 编号。另外,注意到我们把计算时间的方式改成每个 block 都会记录开始时间及结束时间。

main 函式部份,修改成:

int* gpudata, *result;
    clock_t* time;
    cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
    cudaMalloc((void**) &result,
        sizeof(int) * THREAD_NUM * BLOCK_NUM);
    cudaMalloc((void**) &time, sizeof(clock_t) * BLOCK_NUM * 2);
    cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
        cudaMemcpyHostToDevice);

sumOfSquares<<<BLOCK_NUM, THREAD_NUM, 0>>>(gpudata, result,
        time);

int sum[THREAD_NUM * BLOCK_NUM];
    clock_t time_used[BLOCK_NUM * 2];
    cudaMemcpy(&sum, result, sizeof(int) * THREAD_NUM * BLOCK_NUM,
        cudaMemcpyDeviceToHost);
    cudaMemcpy(&time_used, time, sizeof(clock_t) * BLOCK_NUM * 2,
        cudaMemcpyDeviceToHost);
    cudaFree(gpudata);
    cudaFree(result);
    cudaFree(time);

int final_sum = 0;
    for(int i = 0; i < THREAD_NUM * BLOCK_NUM; i++) {
        final_sum += sum[i];
    }

clock_t min_start, max_end;
    min_start = time_used[0];
    max_end = time_used[BLOCK_NUM];
    for(int i = 1; i < BLOCK_NUM; i++) {
        if(min_start > time_used[i])
            min_start = time_used[i];
        if(max_end < time_used[i + BLOCK_NUM])
            max_end = time_used[i + BLOCK_NUM];
    }

printf("sum: %d time: %d\n", final_sum, max_end - min_start);

基本上我们只是把 result 的大小变大,并修改计算时间的方式,把每个 block 最早的开始时间,和最晚的结束时间相减,取得总运行时间。

这个版本的程序,执行的时间减少很多,在 GeForce 8800GT 上只需要约 150K cycles,相当于 40GB/s 左右的带宽。不过,它在 CPU 上执行的部份,需要的时间加长了(因为 CPU 现在需要加总 8192 个数字)。为了避免这个问题,我们可以让每个 block 把自己的每个 thread 的计算结果进行加总。

Thread 的同步

前面提过,一个 block 内的 thread 可以有共享的内存,也可以进行同步。我们可以利用这一点,让每个 block 内的所有 thread 把自己计算的结果加总起来。把 kernel 改成如下:

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)
{
    extern __shared__ int shared[];
    const int tid = threadIdx.x;
    const int bid = blockIdx.x;
    int i;
    if(tid == 0) time[bid] = clock();
    shared[tid] = 0;
    for(i = bid * THREAD_NUM + tid; i < DATA_SIZE;
        i += BLOCK_NUM * THREAD_NUM) {
       shared[tid] += num[i] * num[i];
    }

__syncthreads();
    if(tid == 0) {
        for(i = 1; i < THREAD_NUM; i++) {
            shared[0] += shared[i];
        }
        result[bid] = shared[0];
    }

if(tid == 0) time[bid + BLOCK_NUM] = clock();
}

利用 __shared__ 声明的变量表示这是 shared memory,是一个 block 中每个 thread 都共享的内存。它会使用在 GPU 上的内存,所以存取的速度相当快,不需要担心 latency 的问题。

__syncthreads() 是一个 CUDA 的内部函数,表示 block 中所有的 thread 都要同步到这个点,才能继续执行。在我们的例子中,由于之后要把所有 thread 计算的结果进行加总,所以我们需要确定每个 thread 都已经把结果写到 shared[tid] 里面了。

接下来,把 main 函式的一部份改成:

int* gpudata, *result;
    clock_t* time;
    cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
    cudaMalloc((void**) &result, sizeof(int) * BLOCK_NUM);
    cudaMalloc((void**) &time, sizeof(clock_t) * BLOCK_NUM * 2);
    cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
        cudaMemcpyHostToDevice);

sumOfSquares<<<BLOCK_NUM, THREAD_NUM,
        THREAD_NUM * sizeof(int)>>>(gpudata, result, time);

int sum[BLOCK_NUM];
    clock_t time_used[BLOCK_NUM * 2];
    cudaMemcpy(&sum, result, sizeof(int) * BLOCK_NUM,
        cudaMemcpyDeviceToHost);
    cudaMemcpy(&time_used, time, sizeof(clock_t) * BLOCK_NUM * 2,
        cudaMemcpyDeviceToHost);
    cudaFree(gpudata);
    cudaFree(result);
    cudaFree(time);

int final_sum = 0;
    for(int i = 0; i < BLOCK_NUM; i++) {
        final_sum += sum[i];
    }

可以注意到,现在 CPU 只需要加总 BLOCK_NUM 也就是 32 个数字就可以了。

不过,这个程序由于在 GPU 上多做了一些动作,所以它的效率会比较差一些。在 GeForce 8800GT 上,它需要约 164K cycles。

当然,效率会变差的一个原因是,在这一版的程序中,最后加总的工作,只由每个 block 的 thread 0 来进行,但这并不是最有效率的方法。理论上,把 256 个数字加总的动作,是可以并行化的。最常见的方法,是透过树状的加法:

把 kernel 改成如下:

__global__ static void sumOfSquares(int *num, int* result,
    clock_t* time)
{
    extern __shared__ int shared[];
    const int tid = threadIdx.x;
    const int bid = blockIdx.x;
    int i;
    int offset = 1, mask = 1;
    if(tid == 0) time[bid] = clock();
    shared[tid] = 0;
    for(i = bid * THREAD_NUM + tid; i < DATA_SIZE;
        i += BLOCK_NUM * THREAD_NUM) {
       shared[tid] += num[i] * num[i];
    }

__syncthreads();
    while(offset < THREAD_NUM) {
        if((tid & mask) == 0) {
            shared[tid] += shared[tid + offset];
        }
        offset += offset;
        mask = offset + mask;
        __syncthreads();
    }

if(tid == 0) {
        result[bid] = shared[0];
        time[bid + BLOCK_NUM] = clock();
    }
}

后面的 while 循环就是进行树状加法。main 函式则不需要修改。

这一版的程序,在 GeForce 8800GT 上执行需要的时间,大约是 140K cycles(相当于约 43GB/s),比完全不在 GPU 上进行加总的版本还快!这是因为,在完全不在 GPU 上进行加总的版本,写入到 global memory 的数据数量很大(8192 个数字),也对效率会有影响。所以,这一版程序不但在 CPU 上的运算需求降低,在 GPU 上也能跑的更快。

进一步改善

上一个版本的树状加法是一般的写法,但是它在 GPU 上执行的时候,会有 share memory 的 bank conflict 的问题(详情在后面介绍 GPU 架构时会提到)。采用下面的方法,可以避免这个问题:

offset = THREAD_NUM / 2;
    while(offset > 0) {
        if(tid < offset) {
            shared[tid] += shared[tid + offset];
        }
        offset >>= 1;
        __syncthreads();
    }

这样同时也省去了 mask 变数。因此,这个版本的执行的效率就可以再提高一些。在 GeForce 8800GT 上,这个版本执行的时间是约 137K cycles。当然,这时差别已经很小了。如果还要再提高效率,可以把树状加法整个展开:

if(tid < 128) { shared[tid] += shared[tid + 128]; }
    __syncthreads();
    if(tid < 64) { shared[tid] += shared[tid + 64]; }
    __syncthreads();
    if(tid < 32) { shared[tid] += shared[tid + 32]; }
    __syncthreads();
    if(tid < 16) { shared[tid] += shared[tid + 16]; }
    __syncthreads();
    if(tid < 8) { shared[tid] += shared[tid + 8]; }
    __syncthreads();
    if(tid < 4) { shared[tid] += shared[tid + 4]; }
    __syncthreads();
    if(tid < 2) { shared[tid] += shared[tid + 2]; }
    __syncthreads();
    if(tid < 1) { shared[tid] += shared[tid + 1]; }
    __syncthreads();

当然这只适用于 THREAD_NUM 是 256 的情形。这样可以再省下约 1000 cycles 左右(约 44GB/s)。

posted on 2009-04-28 19:53 GXW 阅读(...) 评论(...) 编辑 收藏

转载于:https://www.cnblogs.com/Fancyboy2004/archive/2009/04/28/1445635.html

cuda入门——改良第一个 CUDA程序相关推荐

  1. 在docker中构建普通java程序_Docker入门-构建第一个Java程序

    原标题:Docker入门-构建第一个Java程序 定制镜像 准备一个没有第三方依赖的java web项目,可能参考示例maven结构项目: session-web.war 把该war上传到安装有doc ...

  2. 一、SpringMVC基础入门,创建一个HelloWorld程序

    一.SpringMVC基础入门,创建一个HelloWorld程序 1.首先,导入SpringMVC需要的jar包. 2.添加Web.xml配置文件中关于SpringMVC的配置 1 2 3 4 5 6 ...

  3. 入门的第一个C++程序。从键盘输入学生姓名,然后输出指定格式的内容。

    PTA 作业罢了,仅供参考. 入门的第一个C++程序.从键盘输入学生姓名,然后输出指定格式的内容. 输入格式: 学生姓名. 输出格式: Hello, C++! My name is <学生姓名& ...

  4. 第一个java程序书_Java入门(4) 第一个Java程序

    Java入门(4) 第一个Java程序 摘要 通过本章的学习,读者应了解Java语言的两种程序:应用程序.小应用程序的基本结构,能够编译.运行Java应用程序和小应用程序. (2002-09-16 1 ...

  5. Node.js入门以及第一个helloworld程序.

    1.概念:简单的说 Node.js 就是运行在服务端的 JavaScript.学之前需要明白Node.js是无法挑战jsp.php或者asp这种老牌网站的地位的,是永远不会出现在证券.金融这种领域的. ...

  6. Python入门(编写第一个Python程序-Hello Python)

    Python是一基于用C语言实现的,开源的,面向对象的解释型语言 学习一门语言,第一个程序通常都是输出一句hello world,既然开始学习Python,那就输出一个hello Python吧 首先 ...

  7. CUDA入门3.2——使用CUDA实现鱼眼转全景图(CUDA环节)1227更

    算法 算法借鉴了Converting a fisheye image into a panoramic, spherical or perspective projection,核心内容如下: Sof ...

  8. php入门教程 第一个php程序,PHP中的第一个程序

    您几乎可以使用任何您喜欢的文本编辑器,但使用语法突出显示的文本编辑器可以使事 Notepad++是一个方便的Windows小编辑器,具有许多不同语言的语法高亮,包括PHP,HTML,CSS和sql. ...

  9. Servlet入门总结及第一个Servlet程序

    林炳文Evankaka原创作品.转载请注明出处http://blog.csdn.net/evankaka 目录 一了解Servlet的概念 二Servlet技术功能 三 Servlet技术特点 四 S ...

最新文章

  1. Chrome 科研神器!论文代码快速复现
  2. 【bzoj3280】小R的烦恼 费用流
  3. OpenGL 三角形的渲染
  4. 收藏 | 49 个 Python 学习资源
  5. eclipse(或者myeclipse)常用配置方法
  6. 欢迎来到元宇宙的虚拟世界
  7. C语言switch怎么算,超级新手,用switch写了个计算器程序,求指导
  8. mysql 触发器示例_MySQL触发器示例
  9. java for循环返回值_JAVA——for循环
  10. gstat | 空间插值(二)——克里金插值之普通克里金
  11. 设置背景图片的两种方式,并解决手机端背景图片高度自适应问题
  12. APP接口开发过程中的注意事项
  13. PC微信多开源代码-消息钩子+ZwQuerySystemInformation
  14. excel html 颜色,excel怎么设置鼠标横竖颜色
  15. AUTOSAR BSW介绍
  16. 商品属性的选择功能的实现
  17. 通信总线传输速率计算
  18. 设置JSP页脚版权标识
  19. 互联网公司为什么普遍996而不是666
  20. 优化案例2:select标量子查询且主查询排序

热门文章

  1. 【linux】 redhat中设置时区
  2. 如何oracle数据库用户名和密码,Oracle数据库用户名及密码查询
  3. Wondows环境下配置Tomat
  4. linux如何判断光盘是否挂载,LInux下如何挂载光盘找rpm包的方法步骤
  5. gramer法则matlab,东南大学几何与代数matlab实验报告(大一专用).doc
  6. Java ThreadLocal 使用详解
  7. 一行代码不用写,就可以训练、测试、使用模型,这个 star 量 1.5k 的项目帮你做到...
  8. 十一在家都逛哪些技术网站?(程序员必备58个网站汇总)
  9. 碎碎念 | 投资理财那些事
  10. php二维数组拆分成字符串,PHP二维数组切割为字符串并去除重复的值