【CUDA学习笔记】第二篇:CUDA C并行化编程【上半部分】(附案例代码下载方式)

简介: 【CUDA学习笔记】第二篇:CUDA C并行化编程【上半部分】(附案例代码下载方式)

1、CUDA并行编程的内容概要


   在上一篇推文中,讨论了如何安装CUDA并使用它编写程序。尽管示例并不令人印象深刻,但它证明了使用CUDA是非常容易的。

   在本次推文和下一次推文中,继续以这个概念为基础,讨论一下如何使用CUDA为GPU编写高级程序。从变量加法程序开始,然后逐步构建CUDA C中的复杂向量操作示例,同时也会介绍内核如何工作以及如何在CUDA程序中使用设备属性。本部分还会讨论在CUDA程序中向量是如何运算的,以及与CPU处理相比,CUDA如何能加速向量运算。除此之外,还会介绍与CUDA编程相关的术语。


本部分将讨论以下主题(紫色部分为本次推文的内容):

   1、内核调用的概念

   2、在CUDA中创建内核函数并向其传递参数

   3、配置CUDA程序的内核参数和内存分配

   4、CUDA程序中的线程执行

   5、在CUDA程序访问GPU设备属性

   6、在CUDA程序中处理向量

   7、并行通信模型



2、CUDA程序结构


  上一篇推文中介绍了一个非常简单的“Hello,CUDA!”程序,其中展示了一些与CUDA程序相关的重要概念。CUDA程序是在主机或GPU设备上执行的函数的组合。不显示并行性的函数在CPU上执行,显示数据并行性的函数在GPU上执行。GPU编译器在编译期间隔离这些函数。如前一篇所示,在设备上执行的函数是使用__global__关键字定义的,由NVCC编译器编译,而普通的C主机代码是由C编译器编译的。CUDA代码基本上与ANSI C代码相同,只是添加了一些开发数据并行性所需的关键字。


   因此,本次将用一个简单的双变量加法程序来解释与CUDA编程相关的重要概念,如内核调用、从主机到设备传递参数到内核函数、内核参数的配置、利用数据并行性需要的CUDA API,以及发生在主机和设备上的内存分配。


   话不多说,直接上代码:

#include <iostream>
#include <cuda.h>
#include <cuda_runtime.h>
#include <stdio.h>
//定义两数相加的 kernel cuda 函数
__global__ void gpuAdd(int d_a, int d_b, int *d_c) {
/*gpuAdd函数与ANSI C中的一个普通add函数非常相似。它以两个整数变量
d_a和d_b作为输入,并将加法存储在第三个整数指针d_c所指示的内存位置。
设备函数的返回值为void,因为它将结果存储在设备指针指向的内存位置中,
而不显式地返回任何值。*/
*d_c = d_a + d_b;
}
//主函数
int main(void) {
//定义CPU计算时的指针
int h_c;
//定义GPU指针
int *d_c;
//使用cudaMalloc函数在GPU设备上分配d_c的内存;其作用类似于C语言中的Malloc函数
cudaMalloc((void**)&d_c, sizeof(int));
//Kernel call by passing 1 and 4 as inputs and storing answer in d_c
//<< <1,1> >> = 一块设备和一个线程执行gpuAdd函数
//(6, 18, d_c)代表给gpuAdd函数传的参数
gpuAdd << <1, 1 >> > (6, 18, d_c);
//将GPU运算的结果复制到主机设备上,也就是复制到CPU上进行接下来的操作
cudaMemcpy(&h_c, d_c, sizeof(int), cudaMemcpyDeviceToHost);
printf("6 + 18 = %d\n", h_c);
//释放GPU设备上的内存,防止资源占用
cudaFree(d_c);
return 0;
}


2.1、内核调用

   使用ANSI C关键字和CUDA扩展关键字编写的设备代码称为内核。它是主机代码(Host Code)通过内核调用的方式来启动的。简单地说,内核调用的含义是我们从主机代码启动设备代码。内核调用通常会生成大量的块(Block)和线程(Thread)来在GPU上并行地处理数据。内核代码非常类似于普通的C函数,只是这段代码是由多个线程并行执行的。


   它以我们想要启动的内核的名称开始。你应该确保这个内核是使用__global__关键字定义的。然后,它具有<<<>>>内核启动配置,该配置包含内核的配置参数。它可以包含三个用逗号分隔的参数。第一个参数表示希望执行的块数,第二个参数表示每个块将具有的线程数。因此,内核启动所启动的线程总数就是这两个数字的乘积。第三个参数是可选的,它指定内核使用的共享内存的大小。在变量相加程序中,内核启动语法如下:

gpuAdd << <1,1> >> (6, 18, d_c)

   在这里,gpuAdd是想要启动的内核的名称,<<<1,1>>>表示想用每个块一个线程启动一个块,这意味着只启动一个线程。圆括号中的三个参数是传递给内核的参数。这里,传递了两个常数,6和18。第三个参数是指向d_c设备显存的指针。它指向设备显存中的位置,内核将在那里存储相加后的结果。


   程序员必须记住的一件事是,作为参数传递给内核的指针应该仅指向设备显存。如果它指向主机内存,会导致程序崩溃。内核执行完成后,设备指针指向的结果可以复制回主机内存,以供进一步使用。只启动一个线程在设备上执行不是设备资源的最佳使用。


2.2、配置内核参数

   为了在设备上并行启动多个线程,必须在内核调用中配置参数,内核调用是在内核启动配置中编写的。它们指定了Grid中块的数量,和每个块中线程的数量。可以并行启动很多个块,而每个块内又有很多个线程。通常,每个块有512或1024个线程。每个块在流多处理器上运行,一个块中的线程可以通过共享内存(Shared Memory)彼此通信。程序员无法选定哪个流多处理器将执行特定的块,也无法选定块和线程以何种顺序执行。

   假设要并行启动500个线程,你可以对前面解释的内核启动语法进行哪些修改?一种选择是通过以下语法启动一个包含500个线程的块:

gpuAdd << <1,500> >> (6, 18, d_c)

   程序员必须注意,每个块的线程数量不能超过GPU设备所支持的最大限制。


   如果需要要处理一个图像,你可以启动一个16×16的块网格,所有的块都包含16×16个线程。语法如下:

mykernel << < dim3(16, 16), dim3(16, 16) > >> ()

   总之,在启动内核时,块数量和线程数量的配置非常重要。根据正在开发的应用程序和GPU资源的不同,应该谨慎地选择。


2.3、CUDA API函数

   在变量加法程序中,会遇到了一些常规C或C++程序员不熟悉的函数或关键字。这些关键字和函数包括:

   __global__

   cudaMalloc

   cudaMemcpy

   cudaFree

__global__:它与__device__和__host__一起是三个限定符关键字。这个关键字表示一个函数被声明为一个设备函数,当从主机调用时将在设备上执行。应该记住,这个函数只能从主机调用。如果要在设备上执行函数并从设备函数调用函数,则必须使用__device__关键字。__host__关键字用于定义只能从其他主机函数调用的主机函数。这类似于普通的C函数。默认情况下,程序中的所有函数都是主机函数。__host__和__device__都可以同时用于定义任何类型函数。它生成同一个函数的两个副本。一个将在主机上执行,另一个将在设备上执行。


cudaMalloc:它类似于C中用于动态内存分配的Malloc函数。此函数用于在设备上分配特定大小的内存块。


cudaMemcpy:这个函数类似于C中的Memcpy函数,用于将一个内存区域复制到主机或设备上的其他区域。


cudaFree:类似于C中的free函数;


   CUDA除了现有的ANSI C函数之外,还有许多其他的关键字和函数。我们会经常使用这三个函数,因此对它们进行了讨论。要了解更多细节,你可以看一下CUDA的编程指南。


3、在GPU设备上执行线程


#include <iostream>
#include <stdio.h>
__global__ void myfirstkernel(void) {
//blockIdx.x gives the block number of current kernel
printf("Hello!!! I'm thread in block: %d\n", blockIdx.x);
}
int main(void) {
//kernel函数有16个并行块,每个块只有一个线程
myfirstkernel << <16, 1 >> > ();
/*Function used for waiting for all kernels to finish
因为启动内核是一个异步操作,只要发布了内核启动命令,
不等内核执行完成,控制权就会立刻返回给调用内核的CPU线程。*/
cudaDeviceSynchronize();
printf("All threads are finished!\n");
return 0;
}

   从代码中可以看出,正在启动一个内核,它有16个并行块,每个块只有一个线程。在每个执行该段内核代码的线程里,我们打印出来它们各自获取到的块ID。可以认为,并行启动了16个执行相同myfirstkernel代码的线程副本。每个副本线程将拥有一个属于自己的块ID和线程ID。本例中,前者可以通过blockIdx.x的CUDA C的内置变量读取到。后者则可以通过threadIdx.x内置变量读取到。这两个ID将告诉我们正在执行内核的是具体哪个块和其中的哪个线程副本。当你多次运行程序时会发现,每次运行,线程块都是以不同的顺序执行的。一个样本输出如图:

   这个程序还含有额外的一个CUDA函数调用:cudaDeviceSynchronize()。为何要加这句?这是因为启动内核是一个异步操作,只要发布了内核启动命令,不等内核执行完成,控制权就会立刻返回给调用内核的CPU线程。在上述的代码中,CPU线程返回,继续执行的下一句是printf()。而再之后,在内核完成之前,进程就会结束,终止控制台窗口。所以,如果不加上这句同步函数,你就看不到任何的内核执行结果输出。在程序退出后内核生成的输出结果,将没有地方可去,你没法看到它们,因此,如果我们不包含这个指令,你将不会看到任何内核执行的printf语句的输出结果。要能看到内核生成的输出结果,我们必须包含这句同步函数。这样,内核的结果将通过可用的标准输出显示,而应用程序则会在内核执行完成之后才退出。

相关实践学习
部署Stable Diffusion玩转AI绘画(GPU云服务器)
本实验通过在ECS上从零开始部署Stable Diffusion来进行AI绘画创作,开启AIGC盲盒。
相关文章
|
并行计算 计算机视觉 异构计算
【CUDA学习笔记】第三篇:CUDA C并行化编程【下半部分】(附案例代码下载方式)(二)
【CUDA学习笔记】第三篇:CUDA C并行化编程【下半部分】(附案例代码下载方式)(二)
209 0
【CUDA学习笔记】第三篇:CUDA C并行化编程【下半部分】(附案例代码下载方式)(二)
|
存储 并行计算 测试技术
【CUDA学习笔记】第五篇:内存以及案例解释(附案例代码下载方式)(二)
【CUDA学习笔记】第五篇:内存以及案例解释(附案例代码下载方式)(二)
181 0
【CUDA学习笔记】第五篇:内存以及案例解释(附案例代码下载方式)(二)
|
缓存 并行计算 API
【CUDA学习笔记】第三篇:CUDA C并行化编程【下半部分】(附案例代码下载方式)(一)
【CUDA学习笔记】第三篇:CUDA C并行化编程【下半部分】(附案例代码下载方式)(一)
179 0
|
存储 并行计算 计算机视觉
【CUDA学习笔记】第五篇:内存以及案例解释(附案例代码下载方式)(一)
【CUDA学习笔记】第五篇:内存以及案例解释(附案例代码下载方式)(一)
315 0
|
缓存 并行计算 API
|
并行计算 异构计算
|
并行计算 异构计算 数据管理
|
并行计算 Linux 程序员
|
并行计算 API 异构计算