随着多核CPU和众核GPU的到来,并行编程已经得到了业界越来越多的重视,CPU-GPU异构程序能够极大提高现有计算机系统的运算性能,对于科学计算等运算密集型程序有着非常重要的意义。这一系列文章是根据《CUDA C语言编程指南》来整理的,该指南是NVIDIA公司提供的CUDA学习资料,介绍了CUDA编程最基本最核心的概念,是学习CUDA必不可少的阅读材料。
初学CUDA,笔记错误之处在所难免,还请发现问题的诸位读者不吝赐教。
CUDA C是C语言的一个扩展,它允许程序员定义一种被称为内核函数(Kernel Functions)的C函数,内核函数运行在GPU上,一旦启动,CUDA中的每一个线程都将会同时并行地执行内核函数中的代码。
内核函数使用关键字__global__来声明,运行该函数的CUDA线程数则通过<<<...>>>执行配置语法来设置。(参见章节"C语言扩展"),每一个执行内核函数的线程都由一个唯一的线程ID,这一ID可以通过在内核函数中访问threadIdx变量来得到。
下面通过一些示例代码来展示刚刚提到的这些概念该如何应用在编程中:
// Kernel definition __global__ void VecAdd(float* A, float* B, float* C) { int i = threadIdx.x; C[i] = A[i] + B[i]; } int main() { ... // Kernel invocation with N threads VecAdd<<<1, N>>>(A, B, C); ... }
在上面的代码中,N个线程将会并行地同时执行加法运算。
// Kernel definition __global__ void MatAdd(float A[N][N], float B[N][N], float C[N][N]) { int i = threadIdx.x; int j = threadIdx.y; C[i][j] = A[i][j] + B[i][j]; } int main() { ... // Kernel invocation with one block of N * N * 1 threads int numBlocks = 1; dim3 threadsPerBlock(N, N); MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C); ... }
// Kernel definition __global__ void MatAdd(float A[N][N], float B[N][N], float C[N][N]) { int i = blockIdx.x * blockDim.x + threadIdx.x; int j = blockIdx.y * blockDim.y + threadIdx.y; if (i < N && j < N) C[i][j] = A[i][j] + B[i][j]; } int main() { ... // Kernel invocation dim3 threadsPerBlock(16, 16); dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y); MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C); ... }
在GPU上CUDA线程可以访问到的存储资源有很多,每个CUDA线程拥有独立的本地内存(local Memory);每一个线程块(block)都有其独立的共享内存(shared memory),共享内存对于线程块中的每个线程都是可见的,它与线程块具有相同的生存时间;同时,还有一片称为全局内存(global memory)的区域对所有的CUDA线程都是可访问的。
除了上述三种存储资源以外,CUDA还提供了两种只读内存空间:常量内存(constant memory)和纹理内存(texture memory),同全局内存类似,所有的CUDA线程都可以访问它们。对于一些特殊格式的数据,纹理内存提供多种寻址模式以及数据过滤方法来操作内存。这两类存储资源主要用于一些特殊的内存使用场合。
一个程序启动内核函数以后,全局内存、常量内存以及纹理内存将会一直存在直到该程序结束。下面是CUDA的内存层次图:
正如前面异构计算章节所讲,CUDA编程模型假定系统是由主机和设备构成的,它们分别具有自己独立的内存空间。Runtime负责设备内存的分配,回收,拷贝以及在主机和设备间传输数据的工作。
设备内存可以有两种分配方式:线性内存或者CUDA数组
CUDA数组是一块不透明的内存空间,它主要被优化用于纹理存取。
线性内存空间与平时我们访问的内存类似,对于计算能力1.x的设备来说,它存在于一个32位的地址空间。对于更高计算能力的设备而言,它存在于一个40位的地址空间中。因此,单独分配的实体可以使用指针来相互应用。
我们通常使用cudaMalloc()函数分配线性内存空间,使用cudaFree()函数释放线性内存空间,使用cudaMemcpy()函数在主机和设备之间传输数据。下面是CUDA Vector Add代码示例的一些片段:
// Device code __global__ void VecAdd(float *A, float *B, float *C, int N) { int i = blockDim.x * blockIdx.x + threadIdx.x; if (i < N) C[i] = A[i] + B[i]; } // Host code int main() { int N = ...; size_t size = N * sizeof(float); // Allocate input vectors h_A and h_B in host memory float *h_A = (float*)malloc(size); float *h_B = (float*)malloc(size); // Initialize input vectors ... // Allocate vectors in device memory float *d_A, *d_B, *d_C; cudaMalloc(&d_A, size); cudaMalloc(&d_B, size); cudaMalloc(&d_C, size); // Copy vectors from host memory to device memory cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice); cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice); // Invoke kernel int threadsPerBlock = 256; int blocksPerGrid = (N +threadsPerBlock - 1) / threadsPerBlock; VecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N); // Copy result from device memory to host Memory cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost); // Free device memory cudaFree(d_A); cudaFree(d_B); cudaFree(d_C); // Free host memory ... }
片段展示了设备内存的分配,传输以及回收过程。
除了上面展示的方法,我们还可以使用cudaMallocPitch()和cudaMalloc3D()函数来分配线性内存。这些函数能够确保分配的内存满足设备内存访问的对齐要求,对于行地址的访问以及多维数组间的数据传输提供高性能保证,因此非常适合对于二维和三维数组内存空间的分配。下面的代码片段展示了分配和使用尺寸为width x height的二维数组的技术:
// Host code int width = 64, height = 64; float *devPtr; size_t pitch; cudaMallocPitch(&devPtr, &pitch, width * sizeof(float), height); MyKernel<<<100, 512>>>(devPtr, pitch, width, height); // Device code __global__ void MyKernel(float* devPtr, size_t pitch, int width, int height) { for (int r = 0; r < height; ++r) { float* row = (float*)((char*)devPtr + r * pitch); for (int c = 0; c < width; ++c) { float element = row[c]; } } }
下面的代码片段展示了一个尺寸为width x height x depth的三维数组的分配和使用方法:
// Host code int width = 64, height = 64, depth = 64; cudaExtent extent = make_cudaExtent(width * sizeof(float), height, depth); cudaPitchedPtr devPitchedPtr; cudaMalloc3D(&devPitchedPtr, extent); MyKernel<<<100, 512>>>(devPitchedPtr, width, height, depth); // Device code __global__ void MyKernel(cudaPitchedPtr devPitchedPtr, int width, int height, int depth) { char* devPtr = devPitchedPtr.ptr; size_t pitch = devPitchedPtr.pitch; size_t slicePitch = pitch * height; for (int z = 0; z < depth; ++z) { char* slice = devPtr + z * slicePitch; for (int y = 0; y < height; ++y) { float* row = (float*)(slice + y * pitch); for (int x = 0; x < width; ++x) float element = row[x]; } } }更多详细的内容请查阅参考手册。
下面的代码示例展示了多种使用Runtime API访问全局变量的技术:
__constant__ float constData[256]; float data[256]; cudaMemcpyToSymbol(constData, data, sizeof(data)); cudaMemcpyFromSymbol(data, constData, sizeof(data)); __device__ float devData; float value = 3.14f; cudaMemcpyToSymbol(devData, &value, sizeof(float)); __device__ float* devPointer; float* ptr; cudaMalloc(&ptr, 256 * sizeof(float)); cudaMemcpyToSymbol(devPointer, &ptr, sizeof(ptr));
使用cudaGetSymbolAddress()函数可以获得被声明存储在全局内存中的变量地址。为了获得分配内存的大小,可以使用cudaGetSymbolSize()函数。
// Matrices are stored in row-major order: // M(row, col) = *(M.elements + row * M.width + col) typedef struct { int width; int height; float *elements; } Matrix; // Thread block size #define BLOCK_SIZE 16 // Forward declaration of the matrix multiplication kernel __global__ void MatMulKernel(const Matrix, const Matrix, Matrix); // Matrix multiplication - Host code // Matrix dimensions are assumed to be multiples of BLOCK_SIZE void MatMul(const Matrix A, const Matrix B, Matrix C) { // Load A and B to device memory Matrix d_A; d_A.width = A.width; d_A.height = A.height; size_t size = A.width * A.height * sizeof(float); cudaMalloc(&d_A.elements, size); cudaMemcpy(d_A.elements, A.elements, size, cudaMemcpyHostToDevice); Matrix d_B; d_B.width = B.width; d_B.height = B.height; size = B.width * B.height * sizeof(float); cudaMalloc(&d_B.elements, size); cudaMemcpy(d_B.elements, B.elements, size, cudaMemcpyHostToDevice); // Allocate C in device memory Matrix d_C; d_C.width = C.width; d_C.height = C.height; size = C.width * C.height * sizeof(float); cudaMalloc(&d_C.elements, size); // Invoke kernel dim3 dimBlock(BLOCK_SIZE, BLOCK_SIZE); dim3 dimGrid(B.width / dimBlock.x, A.height / dimBlock.y); MatMulKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C); // Read C from device memory cudaMemcpy(C.elements, d_c.elements, size, cudaMemcpyDeviceToHost); // Free device memory cudaFree(d_A.elements); cudaFree(d_B.elements); cudaFree(d_C.elements); } // Matrix multiplication kernel called by MatMul() __global__ void MatMulKernel(Matrix A, Matrix B, Matrix C) { // Each thread computes one element of C // by accumulating results into Cvalue float Cvalue = 0; int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.xl for (int e = 0; e < A.width; ++e) Cvalue += A.elements[row * A.width + e] * B.elements[e * B.width + col]; C.elements[row * C.width + col] = Cvalue; }
// Matrices are stored in row-major order; // M(row, col) = *(M.elements + row * M.stride + col) typedef struct { int width; int height; int stride; float* elements; } Matrix; // Get a matrix element __device__ float GetElement(const Matrix A, int row, int col) { return A.elements[row * A.stride + col]; } // Set a matrix element __device__ void SetElement(Matrix A, int row, int col, float value) { A.elements[row * A.stride + col] = value; } // Get the BLOCK_SIZExBLOCK_SIZE sub-matrix Asub of A that is // located col sub-matrices to the right and row sub-matrices down // from the upper-left corner of A __device__ Matrix GetSubMatrix(Matrix A, int row, int col) { Matrix Asub; Asub.width = BLOCK_SIZE; Asub.height = BLOCK_SIZE; Asub.stride = A.stride; Asub.elements = &A.elements[A.stride * BLOCK_SIZE * row + BLOCK_SIZE * col]; return Asub; } // Thread block size #define BLOCK_SIZE 16 // Forward declaration of the matrix multiplication kernel __global__ void MatMulKernel(const Matrix, const Matrix, Matrix); // Matrix multiplication - Host code // Matrix dimensions are assumed to be multiples of BLOCK_SIZE void MatMul(const Matrix A, const Matrix B, Matrix C) { // Load A and B to device memory Matrix d_A; d_A.width = d_A.stride = A.width; d_A.height = A.height; size_t size = A.width * A.height * sizeof(float); cudaMalloc(&d_A.elements, size); cudaMemcpy(d_A.elements, A.elements, size, cudaMemcpyHostToDevice); Matrix d_B; d_B.width = d_B.stride = B.width; d_B.height = B.height; size = B.width * B.height * sizeof(float); cudaMalloc(&d_B.elements, size); cudaMemcpy(d_B.elements, B.elements, size, cudaMemcpyHostToDevice); // Allocate C in device memory Matrix d_C; d_C.width = d_C.stride = C.width; d_C.height = C.height; size = C.width * C.height * sizeof(float); cudaMalloc(&d_C.elements, size); // Invoke kernel dim3 dimBlock(BLOCK_SIZE, BLOCK_SIZE); dim3 dimGrid(B.width / dimBlock.x, A.height / dimBlock.y); MatMulKernel<<<dimGrid, dimBlock>>>(d_A, d_B, d_C); // Read C from device memory cudaMemcpy(C.elements, d_C.elements, size, cudaMemcpyDeviceToHost); // Free device memory cudaFree(d_A.elements); cudaFree(d_B.elements); cudaFree(d_C.elements); } // Matrix multiplication kernel called by MatMul() __global__ void MatMulKernel(Matrix A, Matrix B, Matrix C) { // Block row and column int blockRow = blockIdx.y; int blockCol = blockIdx.x; // Each thread block computes one sub-matrix Csub of C Matrix Csub = GetSubMatrix(C, blockRow, blockCol); // Each thread computes one element of Csub // by accumulating results into Cvalue float Cvalue = 0; // Thread row and column within Csub int row = threadIdx.y; int col = threadIdx.x; // Look over all the sub-matrices of A and B that are required to compute Csub // Multiply each pair of sub-matrices together and accumulate the results for (int m = 0; m < (A.width / BLOCK_SIZE); ++m) { // Get sub-matrix Asub of A Matrix Asub = GetSubMatrix(A, blockRow, m); // Get sub-matrix Bsub of B Matrix Bsub = GetSubMatrix(B, m, blockCol); // Shared memory used to store Asub and Bsub respectively __shared__ float As[BLOCK_SIZE][BLOCK_SIZE]; __shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE]; // Load Asub and Bsub from device memory to shared memory // Each thread loads one element of each sub-matrix As[row][col] = GetElement(Asub, row, col); Bs[row][col] = GetElement(Bsub, row, col); // Synchronize to make sure the sub-matrices are loaded // before starting the computation __syncthreads(); // Multiply Asub and Bsub together for (int e = 0; e < BLOCK_SIZE; ++e) Cvalue += As[row][e] * Bs[e][col]; // Synchronize to make sure that the preceding computation is done before // loading two new sub-matrices of A and B in the next iteration __syncthreads(); } // Write Csub to device memory // Each thread writes one element SetElement(Csub, row, col, Cvalue); }
对于一些计算能力等于或高于1.1的设备,它们可以将内核启动任务和锁页内存到设备内存的数据传输任务并行执行。应用程序可以检查设备属性中的asyncEngineCount项来确定设备是否支持这一功能。当该项值大于0时代表设备支持这一层次的并行。对于计算能力1.x的设备,该功能不支持通过cudaMallocPitch()函数分配的CUDA数组或2D数组。
一些计算能力2.x或更高的设备可以同时并行执行多个内核函数。应用程序可以检查设备属性中的concurrentKernels项来确定设备是否支持这一功能,值为1代表支持。运算能力3.5的设备在同一时刻能够并行执行的最大内核函数数量为32,运算能力小于3.5的硬件则最多支持同时启动16个内核函数的执行。同时需要注意的是,在一个CUDA上下文中的内核函数不能与另一个CUDA上下文中的内核函数同时执行。使用很多纹理内存或者大量本地内存的内核函数也很可能无法与其它内核函数并行执行。
一些计算能力为2.x或更高的设备可以将锁页内存到设备内存的数据传输和设备内存到锁页内存的数据传输并行执行。应用程序可检查设备属性中的asyncEngineCount项来确定这一功能的支持程度,等于2时表示支持。
应用程序通过流来管理并行。一个流是一个顺次执行的命令序列。不同的流之间并行执行,没有固定的执行顺序。
1、流的创建与销毁
定义一个流的过程通常包括:创建一个流对象,然后指定它为内核启动或者主机设备间数据传输的流参数。下面的一段代码创建了两个流并且在锁页内存中分配了一块float类型的数组hostPtr:
cudaStream_t stream[2]; for (int i = 0; i < 2; ++i) cudaStreamCreate(&stream[i]); float *hostPtr; cudaMallocHost(&hostPtr, 2 * size);下面的代码定义了每一个流的行为:从主机端拷贝数据到设备端,内核启动,从设备端拷贝数据到主机端:
for (int i = 0; i < 2; ++i) { cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size, size, cudaMemcpyHostToDevice, stream[i]); MyKernel<<<100, 512, 0, stream[i]>>>(outputDevPtr + i * size, inputDevPtr + i * size, size); cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size, size, cudaMemcpyDeviceToHost, stream[i]); }
这部分代码中有一点需要注意:为了并行化数据拷贝和内核执行,主机端内存必须分配为锁页(page-locked)内存。
要销毁一个流需要调用函数cudaStreamDestroy()
for (int i = 0; i < 2; ++i) cudaStreamDestroy(stream[i]);cudaStreamDestroy() 函数等待之前流中的指令序列运行完成,然后销毁指定流,将控制权返还给主机端。
2、默认流(Default stream)
在内核启动或者数据拷贝过程中如果不指定流,或者设置流参数为0,则相应的指令将会运行在默认流上,它们也因此而顺次执行。
3、明同步(Explicit Synchronization)
在CUDA中有很多种方式可以用来同步流的执行:
cudaDeviceSynchronize()函数使得主机端线程阻塞直到所有流中的指令执行完成。
cudaStreamSynchronize()函数将一个流对象作为输入参数,用以等待指定流中的所有指令执行完成。
cudaStreamWaitEvent()函数将一个流对象和一个事件作为输入参数,它将延迟该函数调用后在指定流中所有新加入的命令的执行直到指定的事件完成为止。流参数可以为0,在该情形下所有流中的任何新加入的指令都必须等待指定事件的发生,然后才可以执行。
cudaStreamQuery()函数为应用程序提供了一个检测指定流中之前指令是否执行完成的方法。
为了避免同步带来的性能下降,所有上述同步函数最好用于计时目的或者分离错误的内核执行或数据拷贝。
4、暗同步(Implicit Synchronization)
如果任何一个流中正在执行以下操作,那么其它流是不能与其并行运行的:
a. 分配锁页内存空间
b. 设备内存分配
c. 设备内存置位
d. 同一设备两个不同地址间正在进行数据拷贝
e. 默认流中有指令正在执行
f. L1/shared内存配置的转换
对于支持并行内核执行并且计算能力3.0或以下的设备来说,任何一个需要检查依赖性以确定流内核启动是否完成的操作:
a. 只有当前CUDA上下文中所有流中所有之前的内核启动之后才能够启动执行。
b. 将会阻塞所有当前CUDA上下文中的任意流中新加入的内核调用直到内核检查完成。
需要进行依赖性检查的操作包括执行检查的内核启动所在流中的其它指令以及任何在该流上对cudaStreamQuery()函数的调用。因此,应用程序可以遵照以下指导原则来提升潜在并行性:
(1)所有非依赖操作应当比依赖性操作提前进行
(2)任何类型的同步越迟越好
5、重叠行为(Overlapping Behavior)
两个流间重叠行为的数量取决于以下几个因素:
(1)每个流中命令发出的次序
(2)设备是否支持内核启动与数据传输并行
(3)设备是否支持多内核并行启动
(4)设备是否支持多数据传输并行
例如,在不支持并行数据传输的设备上,“流的创建与销毁”章节中代码样例中的操作就不能并行,因为在stream[0]中发出设备端到主机端的数据拷贝后,stream[1]又发出主机端到设备端的数据拷贝命令,这两个命令式不能重叠执行的。假设设备支持数据传输与内核启动并行,那么如下代码:
for (int i = 0; i < 2; ++i) cudaMemcpyAsync(inputDevPtr + i * size, hostPtr + i * size, size, cudaMemcpyHostToDevice, stream[i]); for (int i = 0; i < 2; ++i) MyKernel<<<100, 512, 0, stream[i]>>>(outputDevPtr + i * size, inputDevPtr + i * size, size); for (int i = 0; i < 2; ++i) cudaMemcpyAsync(hostPtr + i * size, outputDevPtr + i * size, size, cudaMemcpyDeviceToHost, stream[i]);可将stream[0]的内核启动和stream[1]从主机端到设备端的数据拷贝重叠起来并行执行。
6、回调函数
CUDA运行时提供了cudaStreamAddCallback()函数以在流中的任意位置插入一个回调函数点。回调函数运行于主机端,如果在默认流中插入回调函数,那么它将等待所有其它流中的命令执行完成之后才会开始执行。
下面的代码展示了回调函数技术的应用:
void CUDART_CB MyCallback(cudaStream_t stream, cudaError_t status, void **data) { printf("Inside callback %d\n", (int)data); } ... for (int i = 0; i < 2; ++i) { cudaMemcpyAsync(devPtrIn[i], hostPtr[i], size, cudaMemcpyHostToDevice, stream[i]); MyKernel<<<100, 512, 0, stream[i]>>>(devPtrOut[i], devPtrIn[i], size); cudaMemcpyAsync(hostPtr[i], devPtrOut[i], size, cudaMemcpyDeviceToHost, stream[i]); cudaStreamAddCallback(stream[i], MyCallback, (void**)i, 0); }
上面的代码定义了两个流的操作,每个流都完成一次主机端到设备端的数据拷贝,一次内核启动,一次设备端到主机端的数据拷贝,最后增加了一个加入回调函数的操作。当设备端代码运行到回调函数点的时候,设备将控制权交还给主机端,主机端运行完成以后再将控制权返还给设备端,然后设备端继续运行。
值得注意的是,在一个回调函数中,一定不能进行任何CUDA API的调用,直接的或者间接的都是不可以的。