【CUDA教程】二、主存与显存

【CUDA教程】二、主存与显存

僕は全身全霊懸けても   即使我拼尽全力投入征战

何時か一刀両断するから  总有一天也会一刀两断

見てみたいなら      如果你想看

待っててよ        就请耐心等待

必ず倒すからさぁ     我一定会打倒所有的困难

——MY FIRST STORY《絶体絶命》

上一篇我介绍了cuda的基本知识,本篇我将会介绍有关主存和显存的相关概念和二者的联系。


__host__,__device__与__global__修饰函数

cuda中引入了三个宏:__host____device____global__,用于修饰函数,使得函数被定位到不同的位置。

那修饰后的函数有什么作用呢?

__host__函数,其实就是我们平常写C/C++所定义的运行在CPU中的函数,这个修饰符通常可以不写,效果是等价的。而__device__函数和__global__函数则是必须运行在GPU的函数,因此必须要显式声明在函数前。

我们来看下例:

#include <cstring>
#include <cstdlib>
#include <cassert>

#include "cuda_runtime.h"
#include "device_launch_parameters.h"

__device__ double triple(double x) {
	//返回x的三倍
	return x * 3;
}

__global__ void kern_AddVector(double* c, double const* a, double const* b, size_t n) {
	//求向量c = a + 3b
	size_t Idx = blockIdx.x * blockDim.x + threadIdx.x;
	if(Idx >= n) return;				//超过数组大小,直接返回

	c[Idx] = a[Idx] + triple(b[Idx]);		//实现向量相加
}

__host__ void addVector(double* c, double const* a, double const* b, size_t n) {
	//申请显存内地址
	double *device_c, *device_a, *device_b;
	assert(cudaSuccess == cudaMalloc(&device_c, sizeof(double) * n));
	assert(cudaSuccess == cudaMalloc(&device_a, sizeof(double) * n));
	assert(cudaSuccess == cudaMalloc(&device_b, sizeof(double) * n));
	//将数据拷贝到显存之中
	assert(cudaSuccess == cudaMemcpy(device_a, a, sizeof(double) * n, cudaMemcpyHostToDevice));
	assert(cudaSuccess == cudaMemcpy(device_b, b, sizeof(double) * n, cudaMemcpyHostToDevice));
	//执行核函数
	size_t thread_count = 1024;
	size_t block_count = (n - 1) / thread_count + 1;
	kern_AddVector<<<block_count, thread_count>>> (device_c, device_a, device_b, n);
	cudaDeviceSynchronize();
	cudaError_t ct = cudaGetLastError();
	assert(cudaSuccess == ct);
	//将显存中的数据拷贝到主存中
	assert(cudaSuccess == cudaMemcpy(c, device_c, sizeof(double) * n, cudaMemcpyDeviceToHost));
	//释放临时变量
	assert(cudaSuccess == cudaFree(device_a));
	assert(cudaSuccess == cudaFree(device_b));
	assert(cudaSuccess == cudaFree(device_c));
}

#include <cstdio>

int main() {
	const size_t N = 10;
	double a[N] = {0.1, 0.2, -0.3, 0.1, 0.5, -0.2, 0.2, -0.3, 0.4, 0.1};
	double b[N] = {0.2, -0.1, -0.1, 0.2, -0.2, 0.2, 0.1, 0.1, 0.1, 0.3};
	double c[N];
	addVector(c, a, b, N);
	for(double& e: c) {
		printf("%lf, ", e);
	}

	return 0;
}

例子中addVector函数就是__host__函数,当然main函数也是__host__函数。

__host__函数可以直接调用__host__函数,但不能直接调用__device__函数;__host__函数可以通过传递运行时参数来调用__global__函数,同样也不能像调用__host__函数那样直接调用。而能调用__device__函数的只有__global__函数或者__device__函数。

比如如果main函数这样写,就会报错:

int main() {
	//__host__函数直接调用__device__函数
	double e3 = triple(e);		//error: calling a __device__ function("triple") from a __host__ function("main") is not allowed

	//不传递运行时参数调用__global__函数
	kern_AddVector(c, a, b, N);	//error: a __global__ function call must be configured
}

事实上,我们现在大多数的显卡都已经支持了sm_50, compute_50及以上的计算能力(我们可以使用上一篇中提到的deviceQuery来获取自己显卡的计算能力),而这更加丰富了我们的调用关系——50之前cuda没有调用栈,所有__device__函数在编译的时候都是内联的;但50之后,__device__函数可以通过调用__device__函数实现直接或者间接的递归;而__device__和__global__函数也可以继续通过传递运行时参数调用__global__函数,实现二级甚至二级以上的并行。用拓扑结构图来表示则是:

cuda的函数调用关系

除了函数被宏修饰,变量也可以被修饰。

__device__,__shared__与__constant__修饰变量

__device__、__shared__与__constant__也是cuda的宏,用于修饰变量(别忘了__device__也可以修饰函数)。三种变量都不会被声明在CPU中,而是在GPU中。

__device__变量即设备端的全局变量,和C/C++的全局变量声明位置一样,只能在所有类和函数外声明。__host__函数无法直接访问__device__变量,但可以通过cuda运行库中的cudaMemcpyToSymbol()以及cudaMemcpyFromSymbol()函数传递或获取到它的值。__device__函数和__global__函数可以直接访问它们,只需要注意不要线程冲突就好。

__shared__变量即块内共享变量,只能在__device__函数或者__global__函数内被声明。__shared__变量不能跨过一个线程块,所以声明时其所在的__global__函数的运行时变量中的块数往往是1——当然也可以是更大的值,但某一个块中的__shared__变量就无法被其他块所访问到。变量声明时不能初始化,但可以对它进行赋值。

__constant__变量即设备端的常量,并不像它的名字那样一成不变——但至少它在__device__函数和__global__函数中的访问权限是只读的,这样它就可以被放在高速缓存中,极大地提升访问效率。声明方法又和C/C++不同:声明时赋初值是无效的,必须在__host__函数中通过cuda运行库中的cudaMemcpyToSymbol()函数传递给它;当然,__host__函数内部也可以用cudaMemcpyFromSymbol()函数获取到它的值。

于是我们又可以丰富上图:

cuda的函数调用关系,以及设备端变量的访问权限

图片是1920×1080的,所以想拿去做壁纸也是没问题的(狗头)。

而如果变量前面没有修饰,那就是寄存器变量(就像C/C++里的寄存器变量),如果是在__device__函数或者__global__函数内,那么每个线程分别持有一个该变量,不会共享,对其读取和修改也只会发生在该线程内。

不过要注意,__device__和__constant__只能声明在全局变量区域,__shared__变量只能声明在核函数内部,类的成员变量和其他函数内的局部变量是无法被上述关键词修饰的。

下面代码是一个例子:

#include <cstdio>
#include <cassert>

#include "cuda_runtime.h"
#include "device_launch_parameters.h"

#define N 10
__device__ int arr[N];

__global__ void print() {
	size_t Idx = blockIdx.x * blockDim.x + threadIdx.x;
	if(Idx >= N) return;

	printf("%d\n", arr[Idx]);
}

int main() {
	int a[N] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
	assert(cudaSuccess == cudaMemcpyToSymbol(arr, a, sizeof(a)));
	print<<<10, 1>>> ();
	cudaDeviceSynchronize();
	assert(cudaSuccess == cudaGetLastError());
}

这里没有使用前文例子中的thread_count和block_count,因为我们明确知道线程数是远小于1024的,甚至核函数内也不需要写大于N则返回的逻辑。

当然,由于是多线程,所以输出是乱序的。但如果交换核数和线程数,因为一个核内线程是轮转调度的,所以输出是顺序的。

cudaMalloc、cudaFree、cudaMemset与cudaMemcpy

cuda_runtime.h库中包含了一些和标准C语言库中的函数非常相近的__host__函数——注意,他们只能在__host__函数中被调用,__global__函数和__device__函数要调用函数原型。

这些函数原型是:

cudaError_t cudaMalloc(void **devPtr, size_t size);
cudaError_t cudaFree(void *devPtr);
cudaError_t cudaMemset(void *devPtr, int value, size_t count);
cudaError_t cudaMemcpy(void *dst, const void *src, size_t count, enum cudaMemcpyKind kind);
cudaError_t cudaMemcpyToSymbol(const void *symbol, const void *src, size_t count, size_t offset = 0, enum cudaMemcpyKind kind = cudaMemcpyHostToDevice);
cudaError_t cudaMemcpyFromSymbol(void *dst, const void *symbol, size_t count, size_t offset = 0, enum cudaMemcpyKind kind = cudaMemcpyDeviceToHost);

前四个函数,我们可以通过名字找到它们在C/C++里的“近亲”:

void* malloc(size_t _Size);
void free(void* _Block);
void* memset(void* _Dst, int _Val, size_t _Size);
void* memcpy(void* _Dst, void const* _Src, size_t _Size);

但是这些函数又清一色地返回了cudaError_t这一枚举类型,所以,我们在申请显存空间时,写法应为:

double* p; const size_t N = 10;
cudaError_t ct = cudaMalloc(&p, sizeof(double) * N);
assert(cudaSuccess == ct);

//对比在内存中的malloc:
p = (double*)malloc(sizeof(double) * N);

相信大家也猜到了cudaFree和cudaMemset的用法,事实上我并不喜欢额外创建一个cudaError_t变量,而是直接放在assert中:

assert(cudaSuccess == cudaMemset(p, 0, sizeof(double) * N));
assert(cudaSuccess == cudaFree(p));

//对比在内存中的memset、free:
memset(p, 0, sizeof(double) * N);
free(p);

cudaMemcpy函数不同于memcpy,它有第四个参数,是cudaMemcpyKind枚举类型,其声明如下:

enum __device_builtin__ cudaMemcpyKind
{
    cudaMemcpyHostToHost          =   0,      /**< Host   -> Host */
    cudaMemcpyHostToDevice        =   1,      /**< Host   -> Device */
    cudaMemcpyDeviceToHost        =   2,      /**< Device -> Host */
    cudaMemcpyDeviceToDevice      =   3,      /**< Device -> Device */
    cudaMemcpyDefault             =   4       /**< Direction of the transfer is inferred from the pointer values. Requires unified virtual addressing */
};

相信注释也写得非常清楚了:

  • cudaMemcpyHostToHost就是从主机端拷贝到主机端,即此时cudaMemcpy等价于memcpy,不属于I/O,耗时最短;
  • cudaMemcpyHostToDevice则是从主机端传送到设备端,即源数据在内存中,目标指针指向了一段显存范围,属于I/O,消耗时间较长;
  • cudaMemcpyDeviceToHost则是从设备端传送到主机端,即源数据在显存中,目标指针指向了一段内存范围,同样属于I/O,消耗时间较长;
  • cudaMemcpyDeviceToDevice则是从设备端拷贝到设备端,CPU只给显卡发送一个信号,不涉及数据交互,因此不属于I/O,不会消耗太多时间。大多数情况下可以异步执行。

下列代码则是一些例子:

/*
 * host_a、host_b是经过malloc或new,或者全局、局部变量的数组,包含有N个int
 * device_a、device_b是经过cudaMalloc的数组,同样包含有N个int
 */

cudaMemcpy(host_a, host_b, sizeof(int) * N, cudaMemcpyHostToHost);		//正确
cudaMemcpy(device_b, host_b, sizeof(int) * N, cudaMemcpyHostToDevice);		//正确
cudaMemcpy(host_a, device_a, sizeof(int) * N, cudaMemcpyDeviceToHost);		//正确
cudaMemcpy(device_b, device_a, sizeof(int) * N, cudaMemcpyDeviceToDevice);	//正确

cudaMemcpy(host_a, device_a, sizeof(int) * N, cudaMemcpyHostToDevice);		//错误,函数返回一个cudaErrorInvalidValue
cudaMemcpy(device_b, device_a, sizeof(int) * N, cudaMemcpyHostToHost);		//错误,函数返回一个cudaErrorInvalidValue
cudaMemcpy(device_b, host_b, sizeof(int) * N, cudaMemcpyDeviceToDevice);	//错误,函数返回一个cudaErrorInvalidValue
cudaMemcpy(host_a, host_b, sizeof(int) * N, cudaMemcpyDeviceToHost);		//错误,函数返回一个cudaErrorInvalidValue

而cudaMemcpyToSymbol()和cudaMemcpyFromSymbol()两个函数,前文也提到了,是用来初始化__device__显存全局变量和__constant__显存常量的。虽然函数有五个变量,但后两个变量我们一般只用其初始值,所以写法通常为:

__constant__ int arr[N];

__host__ void init() {
	int a[N] = {9, 8, 7, 6, 5, 4, 3, 2, 1, 0};
	int b[N];
	assert(cudaSuccess == cudaMemcpyToSymbol(arr, a, sizeof(a)));
	assert(cudaSuccess == cudaMemcpyFromSymbol(b, arr, sizeof(b)));
}

可是正如前文所说的,这些函数都是__host__函数,只能在__host__函数中被调用。那么__global__和__device__函数该如何申请、复制、修改和释放显存数据呢?

设备端的malloc、free、memset和memcpy

没错,这就是答案——在__global__和__device__函数中使用函数的原型:

template<typename T>
__global__ void buildList(T** arrs, size_t size, size_t tot_list) {
	size_t Idx = blockIdx.x * blockDim.x + threadIdx.x;
	if(Idx >= tot_list) return;
	
	arrs[Idx] = (T*)malloc(sizeof(T) * size);
	memset(arrs[Idx], 0, sizeof(T) * size);
}

template<typename T>
__global__ void copyList(T** dsts, const T* const* srcs, size_t size, size_t tot_list) {
	size_t Idx = blockIdx.x * blockDim.x + threadIdx.x;
	if(Idx >= tot_list) return;
	
	memcpy(dsts[Idx], srcs[Idx], sizeof(T) * size);
}

template<typename T>
__global__ void clearList(T** arrs, size_t tot_list) {
	size_t Idx = blockIdx.x * blockDim.x + threadIdx.x;
	if(Idx >= tot_list) return;
	
	free(arrs[Idx]);
}

这三个函数实现了长度为tot_list的指针数组的每一个元素并行申请大小为size的内存并初始化深拷贝指针数组,以及并行释放指针数组中的每一个元素的功能。

编程中可能出现的异常

说了半天,也没有提到cudaError_t具体会返回什么异常。

首先我们看一下枚举类型cudaError_t的常见值:

  • cudaSuccess = 0。这是几乎所有程序继续运行下去的基础,即未发生任何错误。
  • cudaErrorInvalidValue =1。在初学者身上比较常见意为传入API函数的值不在合法区间范围内。通常是一些低级错误,比如在初始化常量时没有使用cudaMemcpyToSymbol而是错误使用了cudaMemcpy、或是在cudaMalloc、cudaMemcpy等函数中传递了空指针等。
  • cudaErrorMemoryAllocation = 2。通常是需要申请内存的函数如cudaMalloc会返回这个错误,一般是申请的内存超过了可用显存大小。
  • cudaErrorInitializationError = 3。任何runtime库中的函数都有可能返回这个异常,但只有可能在第一次调用时返回。因为cuda的初始化方法是lazy context initialization,即直到调用才会初始化,并不会在程序一开始就初始化。
  • cudaErrorCudartUnloading = 4。出现这个异常大多都是误删了cuda驱动。如果出现这个异常,请自行忏悔。
  • cudaErrorInvalidConfiguration = 9。通常是传递运行时参数时超过了显卡的负载,如线程数大于deviceQuery输出的每个核的最大线程数、核数大于网格中最大核数等等。
  • cudaErrorInvalidPitchValue = 12。一般是在使用cudaMemcpy2D、cudaMemcpy3D等拷贝高维数组时,Pitch出现了问题——可能没有申请Pitch,或者Pitch的地址出错等等。
  • cudaErrorInvalidSymbol = 13。即对显存全局变量和常量进行相关操作时,符号名称出错,或进行了多余的格式转换。如你想将数组a拷贝给显存常量arr时,传递的第一个参数可以是单纯的arr,也可以是加引号的"arr",如果写成是转化过的(void*)arr,就会返回这一错误。
  • cudaErrorDuplicateVariableName = 43。意为你在定义全局变量时,出现了多个变量重名的情况,可能在同一文件中,也可能在链接前的不同文件中。
  • cudaErrorNoDevice = 100。你需要检查你的显卡是否支持cuda。
  • cudaErrorFileNotFound = 301。找不到指定文件。
  • cudaErrorSymbolNotFound = 500。找不到符号名。通常是在通过字符串寻找设备符号时出现的,此时需要检查你的拼写。
  • cudaErrorIllegalAddress = 700。你可能搞错了传入API的指针究竟指向了内存空间还是显存空间,或者在核函数访问时发生了数组越界等等,产生了非法地址。一旦出现了这个问题,程序就必须终止才能继续使用cuda。
  • cudaErrorLaunchOutOfResources = 701。你可能使用了过多的线程数或寄存器数,可以deviceQuery一下,然后在项目设置中限制一下寄存器的使用。
  • cudaErrorAssert = 710。即在__global__或__device__函数中的断言assert被触发,在触发的同时cuda往往也会将具体行数、核坐标、线程坐标的信息打印出来。一旦出现了这个问题,程序就必须终止才能继续使用cuda。
  • cudaErrorHardwareStackError = 714。通常是栈溢出,可能是你在递归__global__或__device__函数的层数太多,或函数内局部变量数组开得太大。一旦出现了这个问题,程序就必须终止才能继续使用cuda。
  • cudaErrorLaunchFailure = 719。在执行核函数时发生了内核异常,通常是设备共享内存越界、取消引用无效设备指针等等。一旦出现了这个问题,程序同样必须终止之后才能继续使用cuda。

有关错误代码我将在CUDA教程四中详细介绍。


有关主存、显存、runtime库的基本函数以及cudaError_t的介绍就是这些了。

明天就要考科一了,祝我好运。

编辑于 2021-04-02 12:54