CUDA 学习笔记 (一) 【Chapter3 CUDA初探】

Chapter3


目录

3.1 hello , world !

3.2 核函数调用

3.3 传递参数

3.4 查询设备

3.5 设备属性的使用

3.6 本章小结


3.1 hello , world !

/* hello_world.cu*/
#include "../common/book.h"

int main( void ) {
    printf( "Hello, World!\n" );
    return 0;
}

这个简单的 hello world程序只是为了说明, CUDA C与你熟悉的标准C在很大程度上是没有区别的。这个示例很简单,它能够完全在主机上运行。这个示例引出了本书的一个重要区分:我们将CPU以及系统的内存称为主机,而将GPU及其内存称为设备。这个示例程序与你编写过的代码非常相似,因为它并不考虑主机之外的任何计算设备。

接下来将逐渐完善这个简单示例。我们来看看如何使用GPU(这就是一个设备)来执行代码。在GPU设备上执行的函数通常称为核函数( Kernel)。


3.2 核函数调用

/* simple_kernel.cu */
#include "../common/book.h"

__global__ void kernel( void ) {
}

int main( void ) {
    kernel<<<1,1>>>();
    printf( "Hello, World!\n" );
    return 0;
}

这个程序与最初的“ Hello, World!”相比,多了两个值得注意的地方:

  • 一个空的函数 kernel(),并且带有修饰符 __global__。
  • 对这个空函数的调用,并且带有修饰字符 <<<1,1>>>。

CUDA C为标准C增加了__global__修饰符,__global__修饰符将告诉编译器kernel()函数应该编译为在设备上运行。在这个简单的示例中,函数kernel()将被交给编译设备代码的编译器,而main()函数将被交给主机编译器(与上一个例子一样)。

那么, kernel()的调用究竟代表着什么含义,并且为什么必须加上尖括号和两个数值?注意,这正是使用CUDA C的地方。

我们已经看到, CUDA C需要通过某种语法方法将一个函数标记为“设备代码( DeviceCode)”。这并没有什么特别之处,而只是一种简单的表示方法,表示将主机代码发送到一个编译器,而将设备代码发送到另一个编译器。事实上,这里的关键在于如何在主机代码中调用设备代码。 CUDA C的优势之一在于,它提供了与C在语言级别上的集成,因此这个设备函数调用看上去非常像主机函数调用。在后面将详细介绍在这个函数调用背后发生的动作,但就目前而言,只需知道CUDA编译器和运行时将负责实现从主机代码中调用设备代码

因此,这个看上去有些奇怪的函数调用实际上表示调用设备代码,但为什么要使用尖括号和数字?尖括号表示要将一些参数传递给运行时系统。这些参数并不是传递给设备代码的参数,而是告诉运行时如何启动设备代码。在第4章中,我们将了解这些参数对运行时的作用。传递给设备代码本身的参数是放在圆括号中传递的,就像标准的函数调用一样。


3.3 传递参数

将参数传递给核函数的简单示例:

/* simple_device_call.cu */
#include "../common/book.h"

__device__ int addem( int a, int b ) {
    return a + b;
}

__global__ void add( int a, int b, int *c ) {
    *c = addem( a, b );
}

int main( void ) {
    int c;
    int *dev_c;
    HANDLE_ERROR( cudaMalloc( (void**)&dev_c, sizeof(int) ) );

    add<<<1,1>>>( 2, 7, dev_c );

    HANDLE_ERROR( cudaMemcpy( &c, 
                              dev_c, 
                              sizeof(int),
                              cudaMemcpyDeviceToHost ) );
    printf( "2 + 7 = %d\n", c );
    HANDLE_ERROR( cudaFree( dev_c ) );

    return 0;
}

注意这里增加了多行代码,在这些代码中包含两个概念:

  • 可以像调用C函数那样将参数传递给核函数。
  • 当设备执行任何有用的操作时,都需要分配内存,例如将计算值返回给主机。

将参数传递给核函数:传参过程除了尖括号语法之外,核函数的外表和行为看上去与标准C中的任何函数调用一样。运行时系统负责处理将参数从主机传递给设备的过程中的所有复杂操作。


如何在设备上分配内存和释放内存:

  • cudaMalloc()来分配设备内存
  • cudaFree()释放设备内存
  • 注意:不能在主机代码中使用cudaMalloc()分配的指针进行内存读/写操作

通过cudaMalloc()来分配内存:类似于标准的C函数malloc(),cudaMalloc()函数的作用是告诉CUDA运行时在设备上分配内存。cudaMalloc() 函数有两个参数:第一个参数是一个指针(指向用于保存新分配内存地址的变量);第二个参数是分配内存的大小。除了分配内存的指针不是作为函数的返回值外,这个函数的行为与malloc()是相同的,并且返回类型为void*。

【注意】CUDA C的简单性及其强大功能在很大程度上都是来源于它淡化了主机代码和设备代码之间的差异。然而,程序员一定不能在主机代码中对cudaMalloc()返回的指针进行解引用( Dereference)。主机代码可以将这个指针作为参数传递,对其执行算术运算,甚至可以将其转换为另一种不同的类型。但是,绝对不可以使用这个指针来读取或者写入内存。遗憾的是,编译器无法防止这种错误的发生。我们可以将设备指针的使用限制总结如下:

  • 可以将cudaMalloc()分配的指针传递给在设备上执行的函数。
  • 可以在设备代码中使用cudaMalloc()分配的指针进行内存读/写操作。
  • 可以将cudaMalloc()分配的指针传递给在主机上执行的函数。
  • 不能在主机代码中使用cudaMalloc()分配的指针进行内存读/写操作
  • 不能使用标准C的 free() 函数来释放cudaMalloc()分配的内存。需要调用cudaFree()释放cudaMalloc()分配的内存(这个函数的行为与free()的行为非常相似)。

HANDLE_ERROR():函数调用外层的HANDLE_ERROR()是我们定义的一个宏,作为本书辅助代码的一部分。这个宏只是判断函数调用是否返回了一个错误值,如果是的话,那么将输出相应的错误消息,退出应用程序并将退出码设置为EXIT_FAILURE。虽然你也可以在自己的应用程序中使用这个错误处理码,但这种做法在产品级的代码中很可能是不够的。


访问设备内存----使用设备指针 / 调用cudaMemcpy()函数:

  • 在设备代码中使用设备指针访问设备内存
  • 注意:虽然可以将主机指针传递给设备代码,不能通过主机指针访问设备的内存
  • 在主机代码中调用cudaMemcpy()来访问设备上的内存

在设备代码中使用设备指针:

设备指针的使用方式与标准C中指针的使用方式完全一样。语句*c = a + b的含义同样非常简单:将参数a和b相加,并将结果保存在c指向的内存中。在前面列出了设备指针的使用限制,主机指针的使用也有着类似的限制。虽然可以将主机指针传递给设备代码,不能通过主机指针访问设备的内存。 总之,主机指针只能访问主机代码中的内存,而设备指针也只能访问设备代码中的内存

调用cudaMemcpy():在主机代码中可以通过调用cudaMemcpy()来访问设备上的内存。这个函数调用的行为类似于标准C中的memcpy(),只不过多了一个参数来指定设备内存指针究竟是源指针还是目标指针

【注意】cudaMemcpy()的参数:

  • cudaMemcpyDeviceToHost  将告诉运行时源指针是一个设备指针,而目标指针是一个主机指针(本示例即是如此)
  • cudaMemcpyHostToDevice 将告诉运行时源指针是一个主机指针,而目标指针是一个设备指针。
  • cudaMemcpyDeviceToDevice  将告诉运行时两个指针都是位于设备上。
  • 如果源指针和目标指针都位于主机上,那么可以直接调用标准C的 memcpy() 函数。

3.4 查询设备

由于我们希望在设备上分配内存和执行代码,因此如果在程序中能够知道设备拥有多少内存以及具备哪些功能,那么将非常有用。而且,在一台计算机上拥有多个支持CUDA的设备也是很常见的情形。在这些情况中,我们希望通过某种方式来确定使用的是哪一个处理器。例如,在许多主板中都集成了NVIDIA图形处理器。当计算机生产商或者用户将一块独立的图形处理器添加到计算机时,那么就有了两个支持CUDA的处理器。某些NVIDIA产品,例如GeForce GTX 295,在单块卡上包含了两个GPU,因此使用这类产品的计算机也就拥有了两个支持CUDA的处理器。

在深入研究如何编写设备代码之前,我们需要通过某种机制来判断计算机中当前有哪些设备,以及每个设备都支持哪些功能。幸运的是,可以通过一个非常简单的接口来获得这种信息。首先,我们希望知道在系统中有多少个设备是支持CUDA架构的,并且这些设备能够运行基于CUDA C编写的核函数。要获得CUDA设备的数量,可以调用cudaGetDeviceCount()。这个函数的作用从它的名字就可以看出来。

int count;
HANDLE_ERROR( cudaGetDeviceCount( &count ) );

在调用cudaGetDeviceCount()后,可以对每个设备进行迭代,并查询各个设备的相关信息。CUDA运行时将返回一个cudaDeviceProp类型的结构,其中包含了设备的相关属性。我们可以获得哪些属性?从CUDA 3.0开始,在cudaDeviceProp结构中包含了以下信息:

struct cudaDeviceProp 
{
	char name[256];
	size_t totalGlobalMem;
	size_t sharedMemPerBlock;
	int regsPerBlock;
	int warpSize;
	size_t memPitch;
	int maxThreadsPerBlock;
	int maxThreadsDim[3];
	int maxGridSize[3];
	size_t totalConstMem;
	int major;
	int minor;
	int clockRate;
	size_t textureAlignment;
	int deviceOverlap;
	int multiProcessorCount;
	int kernelExecTimeoutEnabled;
	int integrated;
	int canMapHostMemory;
	int computeMode;
	int maxTexture1D;
	int maxTexture2D[2];
	int maxTexture3D[3];
	int maxTexture2DArray[3];
	int concurrentKernels;
}

其中,有些属性的含义是显而易见的,其他属性的含义如下所示(见表3.1)。
表3.1 CUDA设备属性

--------------------------------------------------------------------------------------------------------------------------------------------------------------
设 备 属 性                              描 述
--------------------------------------------------------------------------------------------------------------------------------------------------------------
char name[256];                          标识设备的ASCII字符串(例如, "GeForce GTX 280")
size_t totalGlobalMem                设备上全局内存的总量,单位为字节
size_t sharedMemPerBlock        在一个线程块( Block)中可使用的最大共享内存数量,单位为字节
int regsPerBlock                         每个线程块中可用的32位寄存器数量
int warpSize                                在一个线程束( Warp)中包含的线程数量
size_t memPitch                         在内存复制中最大的修正量( Pitch),单位为字节
int maxThreadsPerBlock            在一个线程块中可以包含的最大线程数量
int maxThreadsDim[3]                在多维线程块数组中,每一维可以包含的最大线程数量
int maxGridSize[3]                      在一个线程格( Grid)中,每一维可以包含的线程块数量
size_t totalConstMem                 常量内存的总量
int major                                     设备计算功能集( Compute Capability)的主版本号
int minor                                     设备计算功能集的次版本号
size_t textureAlignment             设备的纹理对齐( Texture Alignment)要求
int deviceOverlap                        一个布尔类型值,表示设备是否可以同时执行一个cudaMemory()调用和一个核函数调用
int multiProcessorCount            设备上多处理器的数量
int kernelExecTimeoutEnabled  一个布尔值,表示在该设备上执行的核函数是否存在运行时限制
int integrated                             一个布尔值,表示设备是否是一个集成GPU(即该GPU属于芯片组的一部分而非独立的GPU)
int canMapHostMemory            一个布尔类型的值,表示设备是否将主机内存映射到CUDA设备地址空间
int computeMode                      表示设备的计算模式:默认( Default),独占( Exclusive),或者禁止( Prohibited)
int maxTexture1D                      一维纹理的最大大小
int maxTexture2D[2]                 二维纹理的最大维数
int maxTexture3D[3]                 三维纹理的最大维数
int maxTexture2DArray[3]        二维纹理数组的最大维数
int concurrentKernels              一个布尔类型值,表示设备是否支持在同一个上下文中同时执行多个核函数
--------------------------------------------------------------------------------------------------------------------------------------------------------------

就目前而言,我们不会详细介绍所有这些属性。事实上,在上面的列表中没有给出属性的一些重要细节,因此你需要参考《 NVIDIA CUDA Programming Guide》以了解更多的信息。当开始编写应用程序时,这些属性会非常有用。但就目前而言,我们只是给出了如何查询每个设备并且报告设备的相应属性。下面给出了对设备进行查询的代码:

/* searchDevice.cu */
#include "../common/book.h"

int main( void ) {
    cudaDeviceProp prop;
    int dev;

    HANDLE_ERROR(cudaGetDevice( &dev ));
    printf( "ID of current CUDA device:  %d\n", dev );

    memset( &prop, 0, sizeof( cudaDeviceProp ) );
    prop.major = 1;
    prop.minor = 3;
    HANDLE_ERROR( cudaChooseDevice( &dev, &prop ) );
    printf( "ID of CUDA device closest to revision 1.3:  %d\n", dev );

    HANDLE_ERROR( cudaSetDevice( dev ) );
}

在知道了每个可用的属性后,接下来就可以将注释“对设备的属性执行某些操作”替换为一些具体的操作:
 

/* enum_gpu.cu */
#include "../common/book.h"

int main( void ) {
    cudaDeviceProp  prop;

    int count;
    HANDLE_ERROR( cudaGetDeviceCount( &count ) );
    for (int i=0; i< count; i++) {
        HANDLE_ERROR( cudaGetDeviceProperties( &prop, i ) );
        printf( "   --- General Information for device %d ---\n", i );
        printf( "Name:  %s\n", prop.name );
        printf( "Compute capability:  %d.%d\n", prop.major, prop.minor );
        printf( "Clock rate:  %d\n", prop.clockRate );
        printf( "Device copy overlap:  " );
        if (prop.deviceOverlap)
            printf( "Enabled\n" );
        else
            printf( "Disabled\n");
        printf( "Kernel execution timeout :  " );
        if (prop.kernelExecTimeoutEnabled)
            printf( "Enabled\n" );
        else
            printf( "Disabled\n" );

        printf( "   --- Memory Information for device %d ---\n", i );
        printf( "Total global mem:  %ld\n", prop.totalGlobalMem );
        printf( "Total constant Mem:  %ld\n", prop.totalConstMem );
        printf( "Max mem pitch:  %ld\n", prop.memPitch );
        printf( "Texture Alignment:  %ld\n", prop.textureAlignment );

        printf( "   --- MP Information for device %d ---\n", i );
        printf( "Multiprocessor count:  %d\n",
                    prop.multiProcessorCount );
        printf( "Shared mem per mp:  %ld\n", prop.sharedMemPerBlock );
        printf( "Registers per mp:  %d\n", prop.regsPerBlock );
        printf( "Threads in warp:  %d\n", prop.warpSize );
        printf( "Max threads per block:  %d\n",
                    prop.maxThreadsPerBlock );
        printf( "Max thread dimensions:  (%d, %d, %d)\n",
                    prop.maxThreadsDim[0], prop.maxThreadsDim[1],
                    prop.maxThreadsDim[2] );
        printf( "Max grid dimensions:  (%d, %d, %d)\n",
                    prop.maxGridSize[0], prop.maxGridSize[1],
                    prop.maxGridSize[2] );
        printf( "\n" );
    }
}

3.5 设备属性的使用

除非是编写一个需要输出每个支持CUDA的显卡的详细属性的应用程序,否则我们是否需要了解系统中每个设备的属性?作为软件开发人员,我们希望编写出的软件是最快的,因此可能需要选择拥有最多处理器的GPU来运行代码。或者,如果核函数与CPU之间需要进行密集交互,那么可能需要在集成的GPU上运行代码,因为它可以与CPU共享内存。这两个属性都可以通过cudaGetDeviceProperties()来查询。

假设我们正在编写一个需要使用双精度浮点计算的应用程序。在快速翻阅《 NVIDIA CUDA Programming Guide》的附录A后,我们知道计算功能集的版本为1.3或者更高的显卡才能支持双精度浮点数学计算。因此,要想成功地在应用程序中执行双精度浮点运算, GPU设备至少需要支持1.3或者更高版本的计算功能集。

根据在 cudaGetDeviceCount()和 cudaGetDeviceProperties()中返回的结果,我们可以对每个设备进行迭代,并且查找主版本号大于1,或者主版本号为1且次版本号大于等于3的设备。但是,这种迭代操作执行起来有些繁琐,因此CUDA运行时提供了一种自动方式来执行这个迭代操作。首先,找出我们希望设备拥有的属性并将这些属性填充到一个cudaDeviceProp结构。

cudaDeviceProp prop;
memset( &prop, 0, sizeof( cudaDeviceProp ) );
prop.major = 1;
prop.minor = 3;

在填充完 cudaDeviceProp 结构后,将其传递给 cudaChooseDevice(),这样CUDA运行时将查找是否存在某个设备满足这些条件。 cudaChooseDevice()函数将返回一个设备ID,然后我们可以将这个ID传递给 cudaSetDevice()。随后,所有的设备操作都将在这个设备上执行。

/* set_gpu.cu */
#include "../common/book.h"

int main( void ) {
    cudaDeviceProp  prop;
    int dev;

    HANDLE_ERROR( cudaGetDevice( &dev ) );
    printf( "ID of current CUDA device:  %d\n", dev );

    memset( &prop, 0, sizeof( cudaDeviceProp ) );
    prop.major = 1;
    prop.minor = 3;
    HANDLE_ERROR( cudaChooseDevice( &dev, &prop ) );
    printf( "ID of CUDA device closest to revision 1.3:  %d\n", dev );

    HANDLE_ERROR( cudaSetDevice( dev ) );
}

当前,在系统中拥有多个GPU已是很常见的情况。例如,许多NVIDIA主板芯片组都包含了集成的并且支持CUDA的GPU。当把一个独立的GPU添加到这些系统中时,那么就形成了一个多GPU的平台。而且, NVIDIA的SLI(Scalable Link Interface,可伸缩链路接口)技术使得多个独立的GPU可以并排排列。无论是哪种情况,应用程序都可以从多个GPU中选择最适合的GPU。如果应用程序依赖于GPU的某些特定属性,或者需要在系统中最快的GPU上运行,那么你就需要熟悉这个API,因为CUDA运行时本身并不能保证为应用程序选择最优或者最合适的GPU。


3.6 本章小结

从本质上来说,CUDA C只是对标准C进行了语言级的扩展,通过增加一些修饰符使我们可以指定哪些代码在设备上运行,以及哪些代码在主机上运行。在函数前面添加关键字__global__将告诉编译器把该函数放在GPU上运行。为了使用GPU的专门内存,我们还学习了与C的 malloc(), memcpy()和 free()等API对应的CUDA API。这些函数的CUDA版本,包括cudaMalloc(), cudaMemcpy()以及 cudaFree(),分别实现了分配设备内存,在设备和主机之间复制数据,以及释放设备内存等功能。后面还将介绍一些更有趣的示例,这些示例都是关于如何将GPU设备作为一种大规模并行协处理器来使用。


参考书:《GPU高性能编程CUDA实战》

参考blog: https://blog.csdn.net/w09103419/article/details/52484969ca

猜你喜欢

转载自blog.csdn.net/fuxiaoxiaoyue/article/details/83751064
今日推荐