CUDA 学习笔记
参考资料:
- 《CUDA 编程 基础与实践》樊哲勇
- CUDA Toolkit Documentation
获得 GPU 加速的关键
总结
必要条件
- 数据传输比例较小
- 核函数的算术强度较高
- 核函数中定义的线程数目较多
提高性能的技巧
- 减少主机与设备之间的数据传输
- 提高核函数的算术强度
- 增大核函数的并行规模
SM 流多处理器
官方有时简称 Multiprocessor
SM 的构成
- 寄存器
- 共享内存
- 常量内存的缓存
- 纹理和表面内存的缓存
- L1 缓存
- (常见4个)线程束调度器
- 执行核心:INT32, FP32, FP64, 单精度浮点数超于函数的特殊函数单元(special function unit, SFUs), 混合精度的张量核心(tensor cores)
SM 的占有率
当并行规模较小,有些 SM 占有率为零,导致程序性能低下。当并行规模足够大时,也有可能得到非 100% 的占有率。
考虑指标(查询官方文档 CUDA_Occupancy_Calculator.xls 的图灵架构):
- 一个 SM 最多拥有线程块个数$\ N_b = 16$。
- 一个 SM 最多拥有的线程个数$\ N_t = 1536$。
- 线程块大小最大为$\ 1024$。
当并行规模足够大(核函数配置的总线程数足够多),需要分几种情况讨论 SM 的理论占有率:
- 寄存器核共享内存使用量很少。SM 的占有率完全由线程块大小决定。首先,由于线程束大小是 32,线程块大小最好是 32 的倍数。其次,线程块大小不能小于$\ N_t/N_b$,才可能利用最大线程数量。因此,96 的线程块大小就能获得 100% 的占有率。类似的,128 的线程块大小在开普勒架构下能获得满占有率。(我选择 128)
- 寄存器带来占有率瓶颈。一个 SM 最多有 65536(64K)个寄存器。如果令线程数最大化(1536),那么平均一个线程可用 42.7 个寄存器。当每个线程所用的寄存器个数大于 42 时,SM 的占有率将低于 50%。
- 有限的共享内存对占有率的约束。一个 SM 拥有 65536 字节共享内存。如果线程块大小为 128,且SM 是线程满载(1536),要令占有率为 100%,则网格大小为 12,一个线程块最多有 5461 字节的共享内存。
运行时API查询设备
参考deviceQuery.cpp
全局内存的合理使用
合并访问
全局内存具有最高的延迟,所以配置有缓存,每次 Cache Miss 默认读取 32 字节。由于缓存命中问题,需要提高内存访问的合并度,从而提高效率。简单起见,只考虑全局内存到 L2 缓存。可以定义合并度(degree of coalescing) 为一个线程束请求的字节数除以实际数据传输处理的字节数。合并度体现了显存带宽的利用率。
CUDA 运行时 API 函数分配的内存的首地址至少是256字节的整数倍。
只要确保每当线程束读取一次内存后,该内存对应的32字节范围都被当前线程束利用,则合并度就是100%。
对于一个线程束访问同一个全局内存地址的 4 字节,属于广播式的非合并访问(因为只用了 4 字节而非 32 字节,合并度为 12.5%)。如果内存只读,那么适合采用常量内存。
写优先于只读
有时候合并写入与合并读取无法兼顾,例如矩阵转置。此时应当优先保证写入是合并的。
这是因为从帕斯卡架构开始,若编译器判定一个全局内存变量在整个核函数的范围内都只读,则会自动用函数__ldg()
读取全局内存,附带缓存效果,缓解非合并访问的影响。但对于全局内存的写入,没有类似的优化手段。
共享内存的合理使用
在核函数内,使用__shared__
修饰的变量(数组)将使用共享内存。同一线程块使用同一个共享内存副本,而不同线程块的共享内存是互相独立的,不可见的。
利用共享内存,可以消除一些无法兼顾读写合并的全局内存访问,例如数组归约,先将要读取的数据从全局内存拷贝至共享内存(然后__syncthreads()
同步一下),可以带来细微的性能提升。
对共享内存的访问越频繁,性能提升越明显。
动态共享内存
共享内存大小可以在核函数的执行配置中指定:
1 | <<<grid_size, block_size, sizeof(real) * block_size>>> |
同时要修改共享内存变量的声明方式
1 | // before |
动态和静态的声明方式在性能上几乎没有差别。
避免共享内存的bank冲突
共享内存在物理上被分为 32 个(恰好是线程束大小)个 bank,多个线程同时访问同一个 bank 会导致冲突;多个线程同时访问不同的 bank 能取得更高的性能。
除了开普勒架构(8 字节)外,每 4 个字节被划分到一个 bank。例如 0000
属于 bank0,0004
属于 bank1 …… 0128
属于 bank0。可以看出,每个 bank 的相邻层的地址相差 128。
一个线程束试图同时访问同一个 bank 中的 n 层数据将导致 n 次 内存事务(memory transaction)。亦称为 n 路 bank 冲突。n 很大的 bank 冲突是要尽量避免的。
在矩阵转置中,写入共享内存时易产生 bank 冲突,可以通过修改数组大小解决。
记得双精度浮点数占 8 字节。
原子函数
例如在数组归约(加法)中,要令GPU完成求和,需要使用原子操作。
特性
原子函数在全局内存和共享内存上体现原子性。
原子函数不依赖内存栅栏,不需要线程同步或顺序约束(参考官方文档)。
原子函数只能用于设备函数。
原子性粒度
系统原子性:在任意 CPU 和 GPU 的任意线程上保持原子性。函数名有后缀_system
,如atomicAdd_system
。
设备原子性:在当前 GPU 的任意线程上保持原子性。函数名无附加后缀。
线程块原子性:在当前 GPU 的同一线程块中的任意线程上保持原子性。函数名有后缀_block
,如atomicAdd_block
。
原子函数速查
注意:所有的函数都返回旧值
支持的类型可参考书第 92 页。
1 | // T in {int, unsigned int, unsigned long long int} |
线程束与协作组
SIMT
单指令-多线程(single instruction multiple thread, SIMT):不同线程共享同一个 PC。SIMT 的通病是分支发散(branch divergence),所有的分支会产生串行的时间开销。
在伏特架构之前,一个线程束共享一个 PC。不同线程束之间没有分支发散问题。
从伏特架构开始,引入了独立线程调度(independent thread scheduling)机制。每个线程有自己的 PC,使得需要用户自己控制线程束内的同步。另一个代价是每个线程需要用两个寄存器来做 PC。如果旧代码出现线程束内不安全的问题,可以指定虚拟架构为低于伏特架构的计算能力。
线程束内的线程同步
__syncwarp()
比线程块同步函数__syncthreads()
更加廉价。
1 | // 函数原型 |
其中掩码表示参与同步的线程,默认 32 个线程全部参与。
在分治时,当问题规模缩小到一个线程束内,可以不使用__syncthreads
而用__syncwarp
。
更多线程束内的基本函数
注意:若当前线程不参与,则函数的返回值是无定义的。
线程束表决函数(warp vote functions)
线程束匹配函数(warp match functions)待续
线程束洗牌函数(warp shuffle functions)
线程束矩阵函数(warp matrix functions)待续
线程束表决
1 | unsigned __ballot_sync(unsigned mask, int predicate); |
线程束洗牌
定义“束内指标” int lane_id = threadIdx.x % w
。
w
是逻辑线程束大小,只能取 2、4、8、16、32 中的一个。
1 | T __shfl_sync(unsigned mask, T v, int srcLane, int w=warpSize); |
使用线程束洗牌优化数组归约:
1 | constexpr unsigned int FULL_MASK = 0xffffffff; |
协作组
协作组(cooperative groups)可以看作线程块和线程束同步机制的推广。范围涵盖线程块内部,线程块之间(网格级)及设备之间的同步与协作。
引入头文件和命名空间
1 |
|
线程块级别的协作组
基本类型thread_group
。有如下成员:
void sync()
同步组内所有线程unsigned size()
返回组内总的线程数目unsigned thread_rank()
返回当前线程的组内标号bool is_valid()
检查定义的组是否违反 CUDA 的任何限制
导出类型thread_block
,额外函数:
dim3 group_index()
返回当前线程的线程块指标,相当于 blockIdxdim3 thread_index()
相当于 threadIdx
于是可以抽象出当前线程块:
1 | thread_block g = this_thread_block(); |
可以将 thread_block
进行多次分割(但一组线程的数量只能是 2 的幂次):
1 | thread_group g32 = tiled_partition(this_thread_block(), 32); // 相当于线程束 |
这样的“线程块片”可以模仿线程束的一些行为,比如
1 | unsigned __ballot_sync(int predicate); |
再次优化数组归约
这次我们采用的技巧有:
- 使用静态全局内存
- 调用两次核函数,舍弃原子加法函数(这会提高精确度)
- 提高线程利用率(在规约之前进行小部分求和)
1 |
|
CUDA 流
核函数外部的并行
主要有以下情形:
- 核函数计算与数据传输之间的并行
- 主机计算与数据传输之间的并行
- 不同的数据传输之间的并行
- 核函数计算与主机计算之间的并行
- 不同核函数之间的并行
若两个任务的运行时间相近,则尽量令他们并行。反之,则并行和串行的性能差距不大。
使用 CUDA 流的主要目的是尽量取得核函数外部的并行能力。
使用 CUDA 流
CUDA 流的使用相对简单,只要记住一些规则就好。
CUDA 程序有一个默认流。若 API 不需要指定流句柄,可推测它用于默认流。
创建、销毁 CUDA 流
1
2
3
4
5
6
7
8cudaStream_t stream[5];
for (auto &s : stream) {
cudaStreamCreate(&s); // 创建流,获得句柄
}
// ...
for (auto &s : stream) {
cudaStreamDestroy(s); // 销毁流,句柄不抹除
}检测 CUDA 流是否空闲
1
2cudaError_t cudaStreamSynchronize(cudaStream_t stream); // 阻塞主机,直到 CUDA 流完成所有操作
cudaError_t cudaStreamQuery(cudaStream_t stream); // 返回 cudaSuccess(已完成)/ cudaErrorNotReady(未完成)cudaMemcpy
会阻塞主机。但是核函数的调用总是异步的。应该尽量早地调用核函数,随后再进行主机的计算任务。在核函数配置中指派 CUDA 流:
1
my_kernel<<<N_grid, N_block, N_shared, stream_id>>>(...); // 四个模板参数必须齐全
异步的数据传输需要:
- 使用
cudaMemcpyAsync
函数。 - 涉及的主机内存必须是不可分页的,否则 API 将会退化为同步版本。
异步传输的过程将由 GPU 的 DMA(direct memory access)接管。
- 使用
可以用 CUDA 运行时 API 来申请不可分页的内存(non-pageable memory),又称固定内存(pinned memory)。不可分页意味着操作系统无权修改虚拟地址所对应的物理地址。
1
2
3cudaError_t cudaMallocHost(void **ptr, size_t size);
cudaError_t cudaHostAlloc(void **ptr, size_t size, size_t flags);
cudaError_t cudaFreeHost(void *ptr);良好的 CUDA 流指派会从流水线重叠中获得并行加速。但是 CUDA 流是有启动开销,且硬件环境有限,过多的 CUDA 流会拉低性能。
指令速查
编译器选项
1 | --ptxas-options=-v # 报道每个核函数的寄存器使用数量 |
修饰符
1 | __global__ # 核函数修饰符 |
常用代码
CMake模板
1 | # 2021/08/02 |
报错、计时工具
1 | // widgets.hpp |
精度控制模板
1 | // real.hpp |