核函数的要求
- 核函数的返回类型必须是 void。所以,在核函数中可以用 return 关键字,但不可返回任何值。
- 必须使用限定符 __ global __。也可以加上一些其他 C++ 中的限定符,如 static。限定符的次序可任意。
- 函数名无特殊要求,而且支持 C++ 中的重载(overload),即可以用同一个函数名表示具有不同参数列表的函数。
- 不支持可变数量的参数列表,即参数的个数必须确定。
- 可以向核函数传递非指针变量,其内容对每个线程可见。
- 除非使用统一内存编程机制,否则传给核函数的数组(指针)必
须指向设备内存。 - 核函数不可成为一个类的成员。通常的做法是用一个包装函数调用核函数,而将包装函数定义为类的成员。
- 在计算能力 3.5 之前,核函数之间不能相互调用。从计算能力 3.5 开始,引入了动态并行(dynamic parallelism)机制,在核函数内部可以调用其他核函数,甚至可以调用自己。
- 无论是从主机调用,还是从设备调用,核函数都是在设备中执行。调用核函数时必须指定执行配置,即三括号和它里面的参数。
核函数的注意事项
c++
__global__:在device上执行,从host中调用(一些特定的GPU可以从device上调用),返回值必须是void,不支持可变参数,不能是类成员函数。
c++
__global__定义的核函数是异步的,主机不会主动等待核函数执行完在执行后续操作。
c++
__device__:在device上执行,但仅可从device中调用,不能与__global__共用。
c++
__host__:在主机上执行,仅可以从host上调用,一般忽略不写,不可以与__global__同时使用,但可和__device__同时使用,此时函数会在device和host都编译。
计算线程索引
c++
//! 获取线程ID(1维块和2维网格)
#define getThreadId() (blockDim.x * (blockIdx.x + blockIdx.y * gridDim.x) + threadIdx.x)
//! 获取块ID(2维网格)
#define getBlockId() (blockIdx.x + blockIdx.y * gridDim.x)
//! 计算需要用到的数组元素索引(1维)
const int n = blockDim.x * blockIdx.x + threadIdx.x;
z[n] = x[n] + y[n];
核函数的基本使用
c++
//! 网格大小(线程块的大小),线程块大小(线程块中的线程数)
hello_from_gpu << <2, 4 >> > ();
-
blockDim表示一个线程块里有多少个线程。
c++blockDim.x //! X方向上的线程数 -
blockIdx表示当前线程块在整个网格中是第几块。
c++blockIdx.x //! 当前线程块在 X 维度的索引(从 0 开始计数)。 -
threadIdx表示当前线程在线程块的索引(在哪个索引的线程)
c++threadIdx.x //! X方向的线程索引
threadIdx与blockIdx的区别:
blockIdx是全局唯一的,指明线程所在grid中的位置。(整个网格里,第 0 块就只有一个)threadIdx是局部唯一 的,指明线程所在block中的位置。(每一个块里,都有一个threadIdx.x = 0的线程)。
自定义设备函数
函数执行空间标识符
标识符确定一个函数在哪里被调用以及在哪里执行:
- __ global __ 修饰的函数称为核函数,一般由主机调用,在设备中执行。如果使用动态并行,则也可以在核函数中调用自己或其他核函数。
- 用 __ device__ 修饰的函数叫称为设备函数,只能被核函数或其他设备函数调用,在设备中执行。
- 用 __ host__ 修饰的函数就是主机端的普通 C++ 函数,在主机中被调用,在主机中执行。
- 不能同时用 __ device__ 和 __ global__ 修饰一个函数,即不能将一个函数同时定义为设备函数和核函数。
- 不能同时用 __ host__ 和 __ global__ 修饰一个函数,即不能将一个函数同时定义为主机函数和核函数。
- 编译器决定把设备函数当作内联函数(inline function)或非内联函数,但可以用修饰符 __ noinline__ 建议一个设备函数为非内联函数(编译器不一定接受),也可以用修饰符 __ forceinline__ 建议一个设备函数为内联函数。
c++
//! 有返回值的设备函数
double __device__ add1_device(const double x, const double y)
{
return (x + y);
}
void __global__ add1(const double *x, const double *y, double *z, const int N)
{
const int n = blockDim.x * blockIdx.x + threadIdx.x;
if(n < N)
{
z[n] = add1_device(x[n], y[n]);
}
}
//! 有指针的设备函数
void __device__ add2_device(const double x, const double y, double* z)
{
*z = x + y;
}
void __global__ add2(const double *x, const double *y, double *z, const int N)
{
const int n = blockDim.x * blockIdx.x + threadIdx.x;
if(n < N)
{
add2_device(x[n], y[n], &z[n]);
}
}
//! 引用的设备函数
void __device__ add3_device(const double x, const double y, double &z)
{
z = x + y;
}
void __global__ add3(const double *x, const double *y, double *z, const int N)
{
const int n = blockDim.x * blockIdx.x + threadIdx.x;
if(n < N)
{
add3_device(x[n], y[n], z[n]);
}
}
CUDA程序的错误检测
cuda接口都有一个类型为cudaError_t的返回值,代表了一种错误信息,只有返回值为cudaSuccess时才代表成功调用了接口。
c++
#pragma once
#include <stdio.h>
#define CHECK(call) \
do \
{ \
const cudaError_t error_code = call; \
if(error_code != cudaSuccess) \
{ \
printf("CUDA Error:\n"); \
printf("File: %s\n", __FILE__); \
printf("Line: %d\n", __LINE__); \
printf("Error code: %d\n", error_code); \
printf("Error text: %s\n", cudaGetErrorString(error_code)); \
exit(1); \
} \
}while(0) \
检查核函数
由于核函数不返回任何值,采用以下方式可以捕捉调用核函数可能发生的错误。
c++
hello_from_gpu << <2, 4 >> > ();
CHECK(cudaGetLastError());
CHECK(cudaDeviceSynchronize());
第一条语句是捕捉第二个语句之前的最后一个错误,第二个语句的作用是同步主机与设备。之所以要同步主机与设备,是因为核函数的调用是异步的,即主机发出调用核函数的命令后会立即执行后面的语句,不会等待核函数执行完毕。
同步函数是比较耗时的,只建议在调试程序阶段使用。
或者通过将环境变量CUDA_LAUNCH_BLOCKING的值设置为1:
shell
export CUDA_LAUNCH_BLOCKING=1
该环境变量的作用是将核函数的调用从异步改为同步(主机调用核函数之后,必须等待核函数执行完毕,才能继续执行,仅在调试阶段使用)。
检查显存错误(CUDA-MEMCHECK)
CUDA-MEMCHECK工具集具体包括memcheck,racecheck,initcheck,synccheck四个工具可由可执行可执行文件cuda-memcheck调用。
c++
cuda-memcheck --tool memcheck [options] app_name [options]
cuda-memcheck --tool racecheck [options] app_name [options]
cuda-memcheck --tool initcheck [options] app_name [options]
cuda-memcheck --tool synccheck [options] app_name [options]
例如:
c++
cuda-memcheck ./LearnCuda.exe
GPU加速
CUDA事件计时
c++
cudaEvent_t start, stop;
CHECK(cudaEventCreate(&start));
CHECK(cudaEventCreate(&stop));
CHECK(cudaEventRecord(start));
cudaEventQuery(start);
//! 运算核函数
CHECK(cudaEventRecord(stop));
CHECK(cudaEventSynchronize(stop));
float elapsed_time;
CHECK(cudaEventElapsedTime(&elapsed_time, start, stop));
printf("Time = %gms.\n", elapsed_time);
CHECK(cudaEventDestroy(start));
CHECK(cudaEventDestroy(stop));
nvprof工具
可用于对CUDA程序进行更多的性能剖析。
c++
nvprof ./a.out
nvprof --unified-memory-profiling off ./a.out
-
第一列是列出的每类操作所用时间的百分比。
-
第二列是每类操作用的总时间。
-
第三列是每类操作被调用的次数。
-
第四列是每类操作单次调用所用时间的平均值。
-
第五列是每类操作单次调用所用时间的最小值。
-
第六列是每类操作单次调用所用时间的最大值。
-
第七列是每类操作的名称,从这里的输出可以看出核函数的执行时间及数据传输所用时间
核函数加速的要点
CUDA 程序能够获得高性能的必要(但不充分)条件有如下几点:
- 数据传输比例较小。
- 核函数的算术强度较高。
- 核函数中定义的线程数目较多。
优化 CUDA 程序时,(主要是指仔细设计算法)做到以下几点:
- 减少主机与设备之间的数据传输。
- 提高核函数的算术强度。
- 增大核函数的并行规模。
CUDA的内存组织
一般来说,延迟低(速度高)的内存容量小,延迟高(速度低)的内存容量大。
当前被处理的数据一般存放于低延迟、低容量的内存中;当前没有被处理但之后将要被处理的大量数据一般存放于高延迟、高容量的内存中。相对于不用分级的内存,用这种分级的内存可以降低延迟,提高计算效率。
| 内存类型 | 物理位置 | 访问权限 | 可见范围 | 生命周期 |
|---|---|---|---|---|
| 全局内存 | 在芯片外 | 可读可写 | 所有线程和主机端 | 由主机分配与释放 |
| 常量内存 | 在芯片外 | 仅可读 | 所有线程和主机端 | 由主机分配与释放 |
| 纹理和表面内存 | 在芯片外 | 一般仅可读 | 所有线程和主机端 | 由主机分配与释放 |
| 寄存器内存 | 在芯片内 | 可读可写 | 单个线程 | 所在线程 |
| 局部内存 | 在芯片外 | 可读可写 | 单个线程 | 所在线程 |
| 共享内存 | 在芯片内 | 可读可写 | 单个线程 | 所在线程 |
全局内存
全局内存的主要角色是为核函数提供数据,并在主机与设备及设备与设备之间传递数
据。首先,我们用 cudaMalloc 函数为全局内存变量分配设备内存。然后,可以直接在核函数中访问分配的内存,改变其中的数据值。
全局内存对整个网格的所有线程可见。也就是说,一个网格的所有线程都可以访问(读或写)传入核函数的设备指针所指向的全局内存中的全部数据。全局内存的生命周期(lifetime)不是由核函数决定的,而是由主机端决定的。
静态全局内存变量操作
静态全局内存变量,其所占内存数量是在编译期间就确定的。而且,这样的静态全局内存变量必须在所有主机与设备函数外部定义,所以是一种"全局的静态全局内存变量"。
在核函数中,可直接对静态全局内存变量进行访问,并不需要将它们以参数的形式传给核函数。不可在主机函数中直接访问静态全局内存变量,但可以用 cudaMemcpyToSymbol 函数 和 cudaMemcpyFromSymbol 函 数 在 静 态 全 局 内 存 与 主 机 内 存 之 间 传 输 数 据。
c++
__device__ int d_x = 1;
__device__ int d_y[2];
void __global__ my_kernel(void)
{
d_y[0] += d_x;
d_y[1] += d_x;
printf("d_x = %d, d_y[0] = %d, d_y[1] = %d.\n", d_x, d_y[0], d_y[1]);
}
void main()
{
int h_y[2] = {10, 20};
//! 主机内存拷贝到设备静态内存
cudaMemcpyToSymbol(d_y, h_y, sizeof(int) * 2);
my_kernel<<<1, 1>>>();
cudaDeviceSynchronize();
cudaMemcpyFromSymbol(h_y, d_y, sizeof(int) * 2);
printf("h_y[0] = %d, h_y[1] = %d.\n", h_y[0], h_y[1]);
}
常量内存
常量内存是有常量缓存的全局内存,数量有限,一共仅有 64 KB。它的可见范围和生命周期与全局内存一样。不同的是,常量内存仅可读、不可写。由于有缓存,常量内存的访问速度比全局内存高,但得到高访问速度的前提是一个线程束中的线程(一个线程块中相邻的 32 个线程)要读取相同的常量内存数据。
一个使用常量内存的方法是在核函数外面用 constant 定义变量,并用前面介绍
的 CUDA 运行时 API 函数 cudaMemcpyToSymbol 将数据从主机端复制到设备的常量内存后
供核函数使用。当计算能力不小于 2.0 时,给核函数传递的参数(传值,不是像全局变量
那样传递指针)就存放在常量内存中,但给核函数传递参数最多只能在一个核函数中使用
4 KB 常量内存。
纹理内存和表面内存
纹理内存(texture memory)和表面内存(surface memory)类似于常量内存,也是一
种具有缓存的全局内存,有相同的可见范围和生命周期,而且一般仅可读(表面内存也可
写)。不同的是,纹理内存和表面内存容量更大,使用方式和常量内存也不一样。
寄存器
在核函数中定义的不加任何限定符的变量一般来说就存放于寄存器(register)中。核函数中定义的不加任何限定符的数组有可能存放于寄存器中,但也有可能存放于局部内存中。另外,以前提到过的各种内建变量,如 gridDim、blockDim、blockIdx、threadIdx 及 warpSize 都保存在特殊的寄存器中。
c++
//! n就是一个寄存器变量
const int n = blockDim.x * blockIdx.x + threadIdx.x;
寄存器变量仅仅被一个线程可见。也就是说,每一个线程都有一个变量 n 的副本。虽然在核函数的代码中用了这同一个变量名,但是不同的线程中该寄存器变量的值是可以不同的。每个线程都只能对它的副本进行读写。寄存器的生命周期也与所属线程的生命周期一致,从定义它开始,到线程消失时结束。
局部内存
局部内存和寄存器几乎一样。核函数中定义的不加任何限定符的变量有可能在寄存器中,也有可能在局部内存中。寄存器中放不下的变量,以及索引值不能在编译时就确定的数组,都有可能放在局部内存中。这种判断是由编译器自动做的。
局部内存在用法上类似于寄存器,但从硬件来看,局部内存只是全局内存的一部分。所以,局部内存的延迟也很高。每个线程最多能使用高达 512 KB 的局部内存,但使用过多会降低程序的性能。
共享内存
共享内存和寄存器类似,存在于芯片上,具有仅次于寄存器的读写速度,数量也有限。
不同于寄存器的是,共享内存对整个线程块可见,其生命周期也与整个线程块一致。也就是说,每个线程块拥有一个共享内存变量的副本。共享内存变量的值在不同的线程块中可以不同。一个线程块中的所有线程都可以访问该线程块的共享内存变量副本,但是不能访问其他线程块的共享内存变量副本。共享内存的主要作用是减少对全局内存的访问,或者改善对全局内存的访问模式。
SM(流多处理器)及其占有率
一个线程块上的线程是放在同一个流式多处理器上的,单个SM的资源有限,导致线程块中的线程数量是限制的,现代GPU的线程块可支持的线程数可达1024个。
一个GPU 是由多个 SM 构成的。一个 SM 包含如下资源:
- 一定数量的寄存器
- 一定数量的共享内存
- 常量内存的缓存
- 纹理和表面内存的缓存
- 两个(计算能力 6.0)或 4 个(其他计算能力)线程束调度器(warp scheduler),用于
在不同线程的上下文之间迅速地切换,以及为准备就绪的线程束发出执行指令。 - 执行核心包括:
- 若干整型数运算的核心。
- 若干单精度浮点数运算的核心。
- 若干双精度浮点数运算的核心。
- 若干单精度浮点数超越函数的特殊函数单元。
- 若干混合精度的张量核心。
一个 SM 中的各种计算资源是有限的,那么有些情况下一个 SM 中驻留的线程数目就有可能达不到理想的最大值。此时,我们说该 SM 的占有率小于 100%。获得 100% 的占有率并不是获得高性能的必要或充分条件,但一般来说,要尽量让 SM 的占有率不小于某个值,比如 25%,才有可能获得较高的性能。
| 计算能力 | 3.5 | 6.0 | 7.0 | 7.5 |
|---|---|---|---|---|
| GPU 代表 | Tesla K40 | Tesla P100 | Tesla V100 | Geforce RTX 2080 |
| SM 寄存器数上限 | 64 *1024 | 64 * 1024 | 64 * 1024 | 64 * 1024 |
| 单个线程块寄存器数上限 | 64 * 1024 | 64 * 1024 | 64 * 1024 | 64 * 1024 |
| 单个线程寄存器数上限 | 255 | 255 | 255 | 255 |
| SM 共享内存上限 | 48 KB | 64 KB | 96 KB | 64 KB |
| 单个线程块共享内存上限 | 48 KB | 48 KB | 96 KB | 64 KB |
CUDA运行时API函数查询设备
查询显卡设备信息。
c++
int device_id = 0;
cudaSetDevice(device_id);
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, device_id);
printf("Device id: %d\n", device_id);
printf("Device name: %s\n", prop.name);
printf("Compute capability: %d.%d\n", prop.major, prop.minor);
printf("Amount of global memory: %gGB\n", prop.totalGlobalMem / (1024.0 * 1024 * 1024));
printf("Amount of constant memory: %g KB\n",prop.totalConstMem / 1024.0);
printf("Maximum grid size: %d %d %d\n",prop.maxGridSize[0],prop.maxGridSize[1], prop.maxGridSize[2]);
printf("Maximum block size: %d %d %d\n", prop.maxThreadsDim[0], prop.maxThreadsDim[1], prop.maxThreadsDim[2]);
printf("Number of SMs: %d\n", prop.multiProcessorCount);
printf("Maximum amount of shared memory per block: %g KB\n", prop.sharedMemPerBlock / 1024.0);
printf("Maximum amount of shared memory per SM: %g KB\n", prop.sharedMemPerMultiprocessor / 1024.0);
printf("Maximum number of registers per block: %d K\n", prop.regsPerBlock / 1024);
printf("Maximum number of registers per SM: %d K\n", prop.regsPerMultiprocessor / 1024);
printf("Maximum number of threads per block: %d\n", prop.maxThreadsPerBlock);
printf("Maximum number of threads per SM: %d\n", prop.maxThreadsPerMultiProcessor);
