一、认识CUDA
二、CUDA的线程层次结构
CUDA 为许多常用编程语言提供扩展,如 C、C++、Python 和 Fortran 等语言。CUDA 加速程序的文件扩展名是.cu
下面包含两个函数,第一个函数将在 CPU 上运行,第二个将在 GPU 上运行
cpp
void CPUFunction()
{
printf("This function is defined to run on the CPU.\n");
}
__global__ void GPUFunction()
{
printf("This function is defined to run on the GPU.\n");
}
int main()
{
CPUFunction();
GPUFunction<<<1, 1>>>();
cudaDeviceSynchronize();
return 0;
}
- global void GPUFunction()
global 关键字表明以下函数将在 GPU 上运行并可全局调用
将在 CPU 上执行的代码称为主机代码,而将在 GPU 上运行的代码称为设备代码
注意返回类型为 void,使用 global 关键字定义的函数要求返回 void 类型
- GPUFunction<<<1, 1>>>();
当调用要在 GPU 上运行的函数时,将此种函数称为已启动的核函数
启动核函数时,必须提供执行配置,即在向核函数传递任何预期参数之前使用 <<< ... >>> 语法完成的配置。在宏观层面,程序员可通过执行配置为核函数启动指定线程层次结构,从而定义线程组(称为线程块)的数量,以及要在每个线程块中执行的线程数量
- cudaDeviceSynchronize();
与许多 C/C++ 代码不同,核函数启动方式为异步:CPU 代码将继续执行而无需等待核函数完成启动。调用 CUDA 运行时提供的函数 cudaDeviceSynchronize 将导致主机 (CPU) 代码暂作等待,直至设备 (GPU) 代码执行完成,才能在 CPU 上恢复执行
2.1 线程层次结构
GPU 可并行执行工作
线程的集合称为块,块的数量很多。每个 block 的线程数是有限制的,因为 block 的所有线程都应该驻留在同一个流式多处理器内核上,并且必须共享该内核的有限内存资源。在当前 GPU 上,一个线程块最多可以包含 1024 个线程
与给定核函数启动相关联的块的集合被称为网格
GPU 函数称为核函数,核函数通过执行配置启动,执行配置定义了网格中的块数以及每个块中的线程数,网格中的每个块均包含相同数量的线程
启动并行运行的核函数
可通过执行配置指定有关如何启动核函数以在多个 GPU 线程中并行运行的详细信息。即可通过执行配置指定线程组(称为线程块或简称为块)数量以及其希望每个线程块所包含的线程数量
执行配置的语法如下:
cpp
<<< NUMBER_OF_BLOCKS, NUMBER_OF_THREADS_PER_BLOCK>>>
启动核函数时,核函数代码由每个已配置的线程块中的每个线程执行
若假设已定义一个名为 someKernel 的核函数:
- someKernel<<<1, 1>>() 配置为在具有单线程的单个线程块中运行后,将只运行一次
- someKernel<<<1, 10>>() 配置为在具有10线程的单个线程块中运行后,将运行10次
- someKernel<<<10, 1>>() 配置为在10个线程块(均具有单线程)中运行后,将运行10次
- someKernel<<<10, 10>>() 配置为在10个线程块(均具有10线程)中运行后,将运行100次
CUDA提供的线程层次结构变量
-
网格(Grid):一个网格由多个线程块组成,这些线程块可以在一维、二维或三维空间中排列。网格的大小由 dim3 gridDim 变量指定,其中 gridDim.x、gridDim.y和gridDim.z 分别表示网格在x、y和z轴上的大小
-
线程块(Block):一个线程块包含多个线程,这些线程在同一个SM(Streaming Multiprocessor)上并发执行。线程块的大小由 dim3 blockDim 变量指定,其中 blockDim.x、blockDim.y和blockDim.z 分别表示线程块在x、y和z轴上的大小
-
线程(Thread):每个线程块中的线程都有一个唯一的线程ID,由 threadIdx 变量表示。同样,每个线程块在网格中也有一个唯一的块ID,由 blockIdx 变量表示
blockIdx.x 就是当前线程块在网格x轴上的索引。若网格是一维的,blockIdx.x 就足够用来唯一标识每个线程块了。若网格是二维或三维的,还需要使用 blockIdx.y和blockIdx.z 来分别表示线程块在y轴和z轴上的索引
2.2 协调并行线程
元素数量与线程数匹配
假设数据位于索引为 0 的向量中,由于某种未知原因,必须映射每个线程以处理向量中的元素
公式 threadIdx.x + blockIdx.x * blockDim.x 可将每个线程映射到向量的元素中
threadIdx.x的取值为0到3,blockIdx.x的取值为0到1,blockDim.x的取值为4
元素数量小于线程数
上述这种场景中,网络中的线程数与元素数量完全匹配,若线程数超过要完成的工作量,该怎么办?尝试访问不存在的元素会导致运行时错误
鉴于 GPU 的硬件特性,所含线程的数量为 32 的倍数的线程块是最理想的选择,此时具备性能上的优势。假设要启动一些线程块且每个线程块中均包含 256 个线程(32 的倍数),并需运行 1000 个并行任务(此处使用极小的数量以便于说明),则任何数量的线程块均无法在网格中精确生成 1000 个总线程,因为没有任何整数值在乘以 32 后可以恰好等于1000
- 编写执行配置,使其创建的线程数超过执行分配工作所需的线程数
- 将一个值作为参数传递到核函数 (N) 中,该值表示要处理的数据集总大小或完成工作所需的总线程数
- 计算网格内的线程索引后(使用 threadIdx + blockIdx * blockDim),请检查该索引是否超过 N,并且只在不超过的情况下执行与核函数相关的工作
以下是编写执行配置的惯用方法示例,适用于 N 和线程块中的线程数已知,但无法保证网格中的线程数和 N 之间完全匹配的情况。可确保网格中至少始终拥有 N 所需的线程数,且超出的线程数至多不会超过 1 个线程块的线程数量:
cpp
// Assume `N` is known
int N = 100000;
// Assume we have a desire to set `threads_per_block` exactly to `256`
size_t threads_per_block = 256;
// Ensure there are at least `N` threads in the grid, but only 1 block's worth extra
size_t number_of_blocks = (N + threads_per_block - 1) / threads_per_block;
some_kernel<<<number_of_blocks, threads_per_block>>>(N);
cpp
__global__ some_kernel(int N)
{
int idx = threadIdx.x + blockIdx.x * blockDim.x;
if (idx < N) // Check to make sure `idx` maps to some value within `N`
{
// Only do work if it does
}
}
元素数量大于线程数
数据元素数量往往会大于网格中的线程数。在此情况下,线程无法只处理一个元素
以编程方式解决此问题的其中一种方法是使用网格跨度循环,在网格跨度循环中,线程的第一个元素依旧使用 threadIdx.x + blockIdx.x * blockDim.x 计算得出。然后,线程会按网格中的线程数 (blockDim.x * gridDim.x) 向前迈进,直至其数据索引超出数据元素的数量,所有线程均按此种方式运作,如此便会涵盖所有元素
cpp
__global void kernel(int *a, int N)
{
int indexWithinTheGrid = threadIdx.x + blockIdx.x * blockDim.x;
int gridStride = gridDim.x * blockDim.x;
for (int i = indexWithinTheGrid; i < N; i += gridStride)
{
// do work on a[i];
}
}
三、常用cuda函数
3.1 初始化
当第一次调用任何CUDA运行时API(如cudaMalloc、cudaMemcpy等)时,CUDA Runtime会被初始化。这个初始化过程包括设置必要的内部数据结构、分配资源等,以便CUDA运行时能够管理后续的CUDA操作
每个CUDA设备都有一个与之关联的主上下文。主上下文是设备上的默认上下文,当没有显式创建任何上下文时,所有的CUDA运行时API调用都会在该主上下文中执行。主上下文包含了设备上的全局资源,如内存、纹理、表面等
开发者可以在程序启动时显式地指定哪个GPU成为"默认"设备。这个变化通常通过设置环境变量CUDA_VISIBLE_DEVICES 或在程序中使用CUDA API(如cudaSetDevice)显式选择设备来实现。一旦选择了设备,随后的CUDA运行时初始化就会在这个指定的设备上创建主上下文
在没有显式指定设备的情况下,CUDA程序会默认在编号为0的设备(通常是第一个检测到的GPU)上执行操作
cudaDeviceReset
其作用是重置当前线程所关联的CUDA设备的状态,并释放该设备上所有已分配并未释放的资源
使用场景:
- 在程序结束时,调用该函数可以确保所有已分配的GPU资源都被正确释放,避免内存泄漏
- 若在程序的执行过程中遇到错误或需要中途退出,可以释放已分配的资源,确保设备状态正确
- 在某些情况下,若设备状态出错(如由于之前的错误操作导致设备进入不可预测的状态),调用该函数可以尝试恢复设备到一个可用的状态
注意:
- 在调用该函数前,应确保所有已分配的设备内存和其他资源都已被正确地处理(如过cudaFree释放内存)。尽管其会释放这些资源,但最好还是在代码中显式地进行释放,以提高代码的可读性和可维护性
- 调用该函数后,当前线程与设备的关联关系可能会被重置。若需要继续使用设备,可能需要重新调用cudaSetDevice来设置当前线程要使用的设备
3.2 错误检查
有许多 CUDA 函数(如:内存管理函数)会返回类型为 cudaError_t 的值,该值可用于检查调用函数时是否发生错误
cpp
cudaError_t err;
err = cudaMallocManaged(&a, N) // Assume the existence of `a` and `N`
if (err != cudaSuccess) // `cudaSuccess` is provided by CUDA
printf("Error: %s\n", cudaGetErrorString(err)); // `cudaGetErrorString` is provided by CUDA
启动定义为返回 void 的核函数后,将不会返回类型为 cudaError_t 的值。为检查启动核函数时是否发生错误(如:启动配置错误),CUDA 提供 cudaGetLastError 函数,该函数会返回类型为cudaError_t 的值
cpp
someKernel<<<1, -1>>>(); // -1 is not a valid number of threads.
cudaError_t err;
err = cudaGetLastError(); // `cudaGetLastError` will return the error from above.
if (err != cudaSuccess)
printf("Error: %s\n", cudaGetErrorString(err));
捕捉异步错误(如:在异步核函数执行期间),请务必检查后续同步 CUDA Runtime API 调用所返回的状态(如:cudaDeviceSynchronize);若之前启动的其中一个核函数失败,则将返回错误
cpp
#include <stdio.h>
#include <assert.h>
inline cudaError_t checkCuda(cudaError_t result)
{
if (result != cudaSuccess) {
fprintf(stderr, "CUDA Runtime Error: %s\n", cudaGetErrorString(result));
assert(result == cudaSuccess);
}
return result;
}
int main()
{
// ...
checkCuda(cudaDeviceSynchronize());
}
3.3 设备内存
CUDA 编程模型假设系统由主机和设备组成,主机和设备都有独立的内存。内核在设备内存外运行,因此Runtime提供分配、释放和复制设备内存以及在主机内存和设备内存之间传输数据的函数
CUDA设备内存可以分配为线性内存或CUDA数组:
- 线性内存是存在于一个40位地址空间的设备上,可以通过指针来进行内存的访问
- CUDA数组则是一种不透明的内存布局,优化了内存以便纹理访问。与线性内存不同,CUDA数组的内存布局是由CUDA Runtime管理的,因此开发者不需要关心具体的内存地址和访问模式。这种优化使得CUDA数组在纹理映射和图像处理等应用中具有更高的性能
cudaMalloc、cudaMemcpy、cudaFree 管理线性内存
cpp
// 设备代码
__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];
}
// 主机代码
int main()
{
int N = ...;
size_t size = N * sizeof(float);
// 在主机内存中分配输入向量 h_A 和 h_B
float* h_A = (float*)malloc(size);
float* h_B = (float*)malloc(size);
float* h_C = (float*)malloc(size);
// 初始化输入向量
...
// 在设备内存中分配向量
float* d_A;
cudaMalloc(&d_A, size);
float* d_B;
cudaMalloc(&d_B, size);
float* d_C;
cudaMalloc(&d_C, size);
// 将向量从主机内存拷贝到设备内存中
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);
// 调用内核
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
VecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);
// 将结果从设备内存拷贝到主机内存中
// 主机内存 h_C 存储结果
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);
// 释放设备内存
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);
// 释放主机内存
...
}
cudaMallocPitch、cudaMalloc3D、cudaMemcpy2D、cudaMemcpy3D 管理线性内存
线性内存也可以通过 cudaMallocPitch 和 cudaMalloc3D 进行分配。建议使用这些函数来分配2D或3D数组,因为其可以确保适当填充分配以满足对齐要求,从而确保在访问行地址 或 在2D数组和设备内存的其他区域之间执行拷贝时获得最佳性能
**cudaMallocPitch:**这个函数用于分配一个二维数组所需的线性内存,并返回一个"pitch"(或称为"stride")。Pitch是数组中一行所占的字节数,可能大于数组中一行元素实际所占的字节数,以满足对齐要求。使用这个函数可以确保在访问二维数组的行时获得最佳性能
**cudaMalloc3D:**这个函数用于分配一个三维数组所需的线性内存。接受一个cudaPitchedPtr结构体数组(三维数组的"层")和一个cudaExtent结构体来描述三维数组的尺寸和对齐要求
cpp
// 主机代码
int width = 64, height = 64;
float* devPtr;
size_t pitch;
cudaMallocPitch(&devPtr, &pitch, width * sizeof(float), height);
MyKernel<<<100, 512>>>(devPtr, pitch, width, height);
// 设备代码
__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];
}
}
}
cpp
// 主机代码
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);
// 设备代码
__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];
}
}
}
}
为避免分配过多内存,从而影响系统范围的性能,请根据问题大小向用户请求分配参数。若分配失败,可以回退到其他速度较慢的内存类型,或者返回一个错误
cudaMallocHost
cudaMallocHost分配的内存可以同时被GPU和CPU访问。这种内存被称为页锁定内存(Pinned Memory)或固定内存(Fixed Memory)
页锁定内存始终存在于物理内存中,不会被分配到低速的虚拟内存中。能够保证更高的数据传输性能,并且能够通过DMA(Direct Memory Access)加速与设备端(GPU)的通信。页锁定内存资源是有限的。若分配过多,可能会导致系统整体性能下降
cudaHostRegister
cudaHostRegister可将现有的主机内存区域注册为可被GPU访问的页锁定内存
允许将已经分配的内存(如malloc等)注册为锁页内存。适用于需要将现有内存区域用作GPU访问的场景。注册的内存同样受到锁页内存资源限制的影响。在使用完毕后,需要使用cudaUnregisterHostMemory() 来注销内存
cudaMallocManaged
cudaMallocManaged可分配统一内存(UM),这种内存可以在主机和设备之间自动迁移
基于 按需页面迁移 的机制。当GPU需访问统一内存时,若数据不在GPU内存中,会触发页面迁移。简化了内存管理,因为无需手动管理主机和设备之间的数据传输
统一内存的性能可能受到页面迁移开销的影响。若主机和设备频繁地对同一块内存进行访问,可能会导致"抖动"现象,降低性能
- 异步内存存取
分配统一内存 (UM) 时,内存尚未驻留在主机或设备上。主机或设备尝试访问内存时会发生页错误,此时主机或设备会批量迁移所需的数据。能够执行页错误并按需迁移内存对于加速应用程序简化开发流程大有助益。在处理展示稀疏访问模式的数据时(如:在应用程序实际运行之前无法得知需要处理的数据时),以及数据可能由多个 GPU 设备访问时,按需迁移内存将会带来显著优势
有些情况下(如:在运行时之前需要得知数据,以及需要大量连续的内存块时),可以有效规避页错误和按需数据迁移所产生的开销
通过异步内存存取,可以在应用程序代码使用统一内存 (UM) 前,在后台将其异步迁移至系统中的任何 CPU 或 GPU 设备。减少页错误和按需数据迁移所带来的成本,并进而提高 GPU 核函数和 CPU 函数的性能。预取往往会以更大的数据块来迁移数据,因此其迁移次数要低于按需迁移。此技术非常适用于以下情况:在运行时之前已知数据访问需求且数据访问并未采用稀疏模式
使用cudaMemPrefetchAsync函数将数据预取到当前处于活动状态的 GPU 设备,再预取到 CPU
cpp
int deviceId;
cudaGetDevice(&deviceId); // The ID of the currently active GPU device
cudaMemPrefetchAsync(pointerToSomeUMData, size, deviceId); // Prefetch to GPU device
cudaMemPrefetchAsync(pointerToSomeUMData, size, cudaCpuDeviceId); // Prefetch to host `cudaCpuDeviceId` is a
// built-in CUDA variable
3.4 并发流
流是指一系列指令,且 CUDA 具有默认流。默认情况下,CUDA 核函数会在默认流中运行。在任何流(包括默认流)中,其所含指令(此处为核函数启动)必须在下一个流开始之前完成
未完... ...