本篇文章作为学习CUDA官方文档的学习笔记。
- CUDA C Programming Guide
1. Programming Model
本章介绍了CUDA编程模型背后的主要概念。
1.1 Kernels
CUDA C通过允许程序员定义称为内核的C函数来扩展C,这些函数在被调用时由N个不同的CUDA线程并行执行N次,而不是像常规C函数那样只执行一次。
使用__global__
声明说明符定义内核,并使用新的<<< ... >>>
执行配置语法为给定内核指定调用执行该内核的CUDA线程数。执行内核的每个线程都有一个唯一的线程ID,可以通过内置的threadIdx
变量在内核中访问。
1.2 Thread Hierarchy
为方便起见,threadIdx
是一个3分量向量,因此可以使用一维,二维或三维线程索引来识别线程,从而形成一维,二维或三维块。
线程的索引及其线程ID以直接的方式相互关联:对于一维块,它们是相同的;对于二维线程块(Dx,Dy),索引(x,y)的线程的线程ID是(x + y Dx);对于尺寸为三维的块(Dx,Dy,Dz),索引(x,y,z)的线程的线程ID是(x + y Dx + z Dx Dy)。
每个块的线程数有限制,在当前的GPU上,线程块最多可包含1024个线程。但是,内核可以由多个同形状的线程块执行,因此线程总数等于每个块的线程数乘以块数。
<<< ... >>>
语法中指定的每个块的线程数和每个网格的块数可以是int
或dim3
类型。
网格中的每个块可以通过内置的blockIdx
变量访问的一维,二维或三维索引来识别。线程块的维度可以通过内置的blockDim
变量在内核中访问。
块内的线程可以通过共享存储器共享数据并通过同步它们的执行来协调存储器访问。更准确地说,可以通过调用__syncthreads()
内部函数来指定内核中的同步点;__syncthreads()
充当一个屏障,在该屏障处,块中的所有线程必须等待才能允许任何线程继续。
1.3 Memory Hierarchy
如图2所示,CUDA线程可以在执行期间从多个内存空间访问数据。每个线程都有私有本地内存。每个线程块都具有对块的所有线程可见的共享内存,并且具有与块相同的生存周期。所有线程都可以访问相同的全局内存。
所有线程都可以访问两个额外的只读存储空间:常量内存和纹理内存。全局,常量和纹理内存空间针对不同的内存使用进行了优化。纹理存储器还为某些特定数据格式提供不同的寻址模式以及数据滤波。
全局,常量和纹理内存空间在同一应用程序的内核启动之间是持久的。
[图片上传失败...(image-b4b78f-1543058331988)]
1.4 Heterogeneous Programming
如图4所示,CUDA编程模型假设CUDA线程在物理上独立的设备上执行,该设备作为运行C程序的主机的协处理器运行。例如,内核在GPU上执行而其余的C程序在CPU上执行。
CUDA编程模型还假设主机和设备都在DRAM中保持它们自己独立的存储空间,分别称为主机存储器(host memory)和设备存储器(device memory)。因此,程序通过调用CUDA runtime来管理内核可见的全局,常量和纹理内存空间。这包括设备内存分配和释放以及主机和设备内存之间的数据传输。
统一内存(Unified Memory)提供托管内存(managed memory)以桥接主机和设备内存空间。可以从系统中的所有CPU和GPU访问托管内存,作为具有公共地址空间的单个连贯内存映像。此功能可实现设备内存的超额预订,并且无需在主机和设备上显式镜像数据,从而大大简化了移植应用程序的任务。
Note:串行代码在主机上执行,而并行代码在设备上执行。
1.5 Compute Capability
设备的计算能力由版本号表示,有时也称为“SM版本”。此版本号标识GPU硬件支持的功能,并由运行时的应用程序用于确定当前GPU上可用的硬件功能或指令。
计算能力包括主修订号X和次修订号Y,并由X.Y表示。计算能力表格
具有相同主版本号的设备具有相同的核心架构。基于Volta架构的设备的主版本号为7,基于Pascal架构的设备为6,基于Maxwell架构的设备为5,基于Kepler架构的设备为3,基于Fermi架构的设备为2,1用于基于Tesla架构的设备。
次修订号对应于核心架构的增量改进,可能包括新功能。
Turing是计算能力7.5设备的架构,是基于Volta架构的增量更新。
2. Programming Interface
2.1 Compilation with NVCC
可以使用称为PTX的CUDA指令集架构来编写内核。然而,使用诸如C的高级编程语言通常更有效。在这两种情况下,必须通过nvcc
将内核编译成二进制代码以在设备上执行。
nvcc
是一个编译器驱动程序,它简化了编译C或PTX代码的过程:它提供了简单而熟悉的命令行选项,并通过调用实现不同编译阶段的工具集来执行它们。本节概述了nvcc
工作流和命令选项。
2.1.1 Compilation Workflow
2.1.1.1 Offline Compilation
用nvcc
编译的源文件可以包括主机代码(即,在主机上执行的代码)和设备代码(即,在设备上执行的代码)的混合。nvcc
的基本工作流程包括将设备代码与主机代码分离,然后:
- 将设备代码编译为汇编表单(PTX代码)或二进制表单(cubin对象);
- 通过必要的CUDA C运行时函数调用替换内核中引入的
<<< ... >>>
语法来修改主机代码,以从PTX代码加载和启动每个编译的内核或cubin对象。
修改后的主机代码既可以作为C代码输出,也可以使用其他工具进行编译,也可以通过让nvcc
在上一个编译阶段调用主机编译器直接输出目标代码。
然后应用程序:
- 要么链接到已编译的主机代码(这是最常见的情况);
- 要么忽略修改后的主机代码(如果有)并使用CUDA驱动程序API来加载和执行PTX代码或cubin对象。
2.1.1.2 Just-in-Time Compilation
应用程序在运行时加载的任何PTX代码都由设备驱动程序进一步编译为二进制代码,这称为即时编译。即时编译会增加应用程序加载时间,但允许应用程序受益于每个新设备驱动程序随附的任何新编译器改进。它也是应用程序在编译应用程序时不存在的设备上运行的唯一方法。
当设备驱动程序及时为某些应用程序编译某些PTX代码时,它会自动缓存生成的二进制代码的副本,以避免在后续的应用程序调用中重复编译。缓存(称为计算缓存)在升级设备驱动程序时自动失效,因此应用程序可以从设备驱动程序中内置的新实时编译器的改进中受益。
环境变量可用于控制即时编译。
2.2 CUDA C Runtime
正如Heterogeneous Programming中所提到的,CUDA编程模型假设一个由主机和设备组成的系统,每个系统都有自己独立的内存。Device Memory概述了用于管理设备存储器的runtime functions。
Shared Memory说明了在线程层次结构中引入的共享内存的使用,以最大限度地提高性能。
Page-Locked Host Memory引入了页面锁定主机内存,该内存是将内核执行与主机和设备内存之间的数据传输重叠所必需的。
2.2.1 Initialization
runtime 没有明确的初始化函数;在第一次调用runtime function 时初始化(更具体地说,除了参考手册的设备和版本管理部分中的函数之外的任何函数)。
在初始化期间,runtime 为系统中的每个设备创建CUDA上下文。此上下文是此设备的主要上下文(primary context),它在应用程序的所有主机线程之间共享。作为此上下文创建的一部分,设备代码在必要时即时编译并加载到设备内存中。这一切都发生在幕后,runtime 不会将主要上下文暴露给应用程序。
当主机线程调用cudaDeviceReset()
时,这会破坏主机线程当前操作的设备的主要上下文(即,设备选择中定义的当前设备)。由此设备作为当前主机线程进行的下一个运行时函数调用将为此设备创建新的主要上下文。
2.2.2 Device Memory
核函数在设备内存之外运行,因此 runtime 提供分配,释放和复制设备内存的功能,以及在主机内存和设备内存之间传输数据的功能。
设备存储器可以分配为线性内存(linear memory)或CUDA阵列(CUDA arrays)。
CUDA数组是不透明的内存布局,针对纹理提取进行了优化。
线性内存存在于40位地址空间中的设备上,因此单独分配的实体可以通过指针相互引用,例如,在二叉树中。
线性内存通常使用cudaMalloc()
分配,并使用cudaFree()
释放,主机存储器和设备存储器之间的数据传输通常使用cudaMemcpy()
完成。在 Kernels 的向量加法代码示例中,向量需要从主机内存复制到设备内存:
// 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;
cudaMalloc(&d_A, size);
float* d_B;
cudaMalloc(&d_B, size);
float* d_C;
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<<>>(d_A, d_B, d_C, N);
// Copy result from device memory to host memory
// h_C contains the result in 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()
分配。建议将这些函数用于2D或3D阵列的分配,因为它确保分配被适当填充以满足对齐要求,从而确保在访问行地址或在2D阵列与其他区域之间执行复制时的最佳性能(使用cudaMemcpy2D()
和cudaMemcpy3D()
函数)。返回的 pitch(或 stride)必须用于存取数组元素。以下代码示例分配一个width
xheight
的二维浮点数组,并显示如何在设备代码中循环数组元素:
// 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
xheight
xdepth
的三维浮点值数组,并显示如何在设备代码中循环数组元素:
// 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];
}
}
}
}
cudaGetSymbolAddress()
用于取回指向全局内存空间中声明的变量内存地址。分配的内存大小通过cudaGetSymbolSize()
获得。
2.2.3 Shared Memory
共享内存是使用__shared__
内存空间说明符分配的。
共享内存一般比全局内存快得多,因此,应该利用共享内存访问替换全局内存访问的任何机会,如以下矩阵乘法示例所示。
下面的代码示例是矩阵乘法的简单实现,它不利用共享内存。每个线程读取A的一行和B的一列,并计算C的相应元素,如图5所示。因此 A 是从全局内存读取 B.width 的次数,B 是读取 A.height 的次数。
// 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<<>>(d_A, d_B, d_C);
// Read C from device memory
cudaMemcpy(C.elements, Cd.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.x;
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;
}
以下代码示例是使用共享内存实现矩阵乘法。在该实现中,每个线程块负责计算C的一个方形子矩阵,并且块内的每个线程负责计算的一个元素。如图 6 所示,等于两个矩形矩阵的乘积:A 的子矩阵(A.width, block_size)与具有相同的行索引;B 的子矩阵(block_size, A.width)与具有相同的列索引。为了适应设备资源,这两个矩形矩阵根据需要被分成block_size大小的多个方阵,并且为这些方阵的乘积之和。首先将两个对应的方阵从全局内存加载到共享内存中,其中每一个线程加载每个方阵中的一个元素,然后让每个线程计算一次乘积运算。每个线程将这些乘积的结果累积到一个寄存器中,一旦完成就将结果写入全局内存。
通过这种分块运算,充分利用了更为快速的共享内存并且节省了大量全局内存带宽,因为 A 只从全局内存中读取(B.width / block_size)次而 B 只读取(A.height / block_size)次。
来自前一段代码示例的 Matrix 类型使用 stride 字段进行扩充,以便可以使用相同类型有效地表示子矩阵。__device__
函数用于获取和设置元素并从矩阵构建任何子矩阵。
// 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<<>>(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;
// Loop 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);
2.2.4 Page-Locked Host Memory
runtime 函数允许使用页锁定(page-locked)(也称为固定 pinned)主机内存(与malloc()
分配的常规可分页主机内存相对):
-
cudaHostAlloc()
和cudaFreeHost()
分配并释放页面锁定的主机内存; -
cudaHostRegister()
页锁定malloc()
分配的一系列内存。
使用页面锁定主机内存有几个好处:
- 页面锁定主机内存和设备内存之间的副本可以与某些设备的内核执行同时执行,如异步并发执行。
- 在某些设备上,页锁定主机内存可以映射到设备的地址空间,无需将其复制到设备内存或从设备内存复制。
- 在具有前端总线的系统上,如果主机存储器被分配为页面锁定,则主机存储器和设备存储器之间的带宽更高,如果另外它被分配为写组合(write-combining),则会更高。