Cuda

基础知识

结构

在硬件上,一个 GPU 分为多个 SM,每个 SM 包含本地寄存器文件、统一数据缓存(L1 缓存和共享内存)和若干个执行计算的单元

执行一个 GPU 程序先会从 CPU 启动 Kernel (核函数),每一个函数称为一个 Grid,分为不同的 Block,每个 Block 执行多个线程 Thread,一个 Grid 使用的 Block 和每个 Block 中含有的 Thread 可以通过编程自定义 <<<Block,ThreadPerBlock>>>Block 之间无法同步和共享内存(无法确定执行顺序),Cluster 将不同 Block 组织起来使其可以通信和访问共享内存

GPU 的执行单位为 Warp (线程束),它将线程分为每 32 为一组,每组执行完全相同的代码(无分支,对于不同分支屏蔽多余分支只执行一个分支);将 Block 分到多个 SM 中进行计算(一个 SM 可以有多个 Block,但是一个 Block 只能分配到同一个 SM 中进行计算),同一 ClusterBlock被分配到同一 GPC

Tile and SIMT

SIMT 模型中,程序员按“线程”思考和编程,需要手动管理每个线程的数据访问和控制流,因此会出现 Warp Divergence 等问题。

Tile Programming 模型中,程序员按“数据块(tile)”思考和编程,整个 block 作为一个整体执行统一控制流,编译器自动把 tile 操作映射到线程,因此不再存在 Warp Divergence 的概念。

Tile 编程中,只需关注数据在数据块中如何运算,由编译器负责线程的组织。在这种编程方式下有两种数据类型,一个为数组,一个为 Tile (瓦片),瓦片不可变(对瓦片的操作只会产生新的瓦片),每个维度必须是 2 的幂并且编译期可知(不可动态);数组和瓦片之间的数据可以进行移动,将数组分为多个小块,自带边界策略将超过边界的内容自动填充为 0

TileSIMT 可在同一程序中同时出现

GPU内存

为了将一个线程块调度到 SM 上,每个线程所需的寄存器总数乘以线程块中的线程数必须小于或等于 SM 中可用的寄存器数量

每个流式多处理器(SM)都包含一个 L1 缓存,该缓存是统一数据缓存的一部分。更大的 L2 缓存则由 GPU 内所有 SM 共享

CPU 内存只能由 CPU 代码访问,而 GPU 内存只能由运行在 GPU 上的内核访问。CUDA API 用于在 CPUGPU 之间复制内存,被用来在正确的时间将数据显式复制到正确的内存位置

Cubin

一个 Cuda 二进制文件包含 CPU 机器码和一个 fatbin 容器,这个容器中包含针对不同版本的 SMcubinRTX,当该二进制程序运行时会从 fatbin 中选择最适合 GPU 的二进制文件 cubin,或者使用 RTX JIT

GPU编程(C++)

头文件

#include <cuda_runtime_api.h>

内核函数

__global__ void (function name)(argv){

}

内核函数的启动可以由三尖括号

__global__ void vecAdd(float* a,float*b ,float* c){
// ...
}

int main(){
vecAdd<<<1,256>>>(A,B,C);
}

内核函数启动后,默认情况下 CPU 不会等待 GPU 执行完毕

使用 cudaDeviceSynchronize()CPU 等待 GPU 执行完毕

__syncthreads() 使块内线程等待直到所有线程都到达该点

线程块

可以通过变量设置块数和每块线程数

dim3 grid(16,16); // block:256
dim3 block(8,8); // threadPerBlock:64
vecAdd<<<grid,block>>>(A,B,C);

启动配置中未指定的维度将默认为 1

为了精确控制线程的执行,我们具有一些内建变量

  • threadIdx
  • blockDim
  • blockIdx
  • gridDim

这些都是三分量向量(dim3

对于已知总线程数和每块线程数,要确定块数,可以使用

>#include <cuda/cmath>
>int thread = 256;
>int block - cuda::ceil_div(totalThread,thread);

内存分配

float* A = nullptr;
cudaMallocManaged(&A,size);
// ...
cudaDeviceSynchronize();
cudaFree(A);

为了提升性能

float* A = nullptr;
float* cudaA = nullptr;
cudaMallocHost(&A,size);
cudaMalloc(&cudaA,size);
cudaMemcpy(cudaA,A,size,cudaMemcpyDefault);
// ...
cudaDeviceSynchronize();
cudaMemcpy(A,cudaA,size,cudaMemcpyDefault);
cudaFree(cudaA);
cudaFreeHost(A);

cudaMemcpyDefault 会在以下类型中选择

  • cudaMemcpyHostToDevice
  • cudaMemcpyDeviceToHost
  • cudaMemcpyDeviceToDevice

错误处理

每个 CUDA API 都会返回一个枚举类型 cudaError_t 的值,在生产应用中,最佳实践是始终检查并管理

#define CUDA_CHECK(expr_to_check) do { \
cudaError_t result = expr_to_check; \
if(result != cudaSuccess) \
{ \
fprintf(stderr, \
"CUDA Runtime Error: %s:%i:%d = %s\n", \
__FILE__, \
__LINE__, \
result,\
cudaGetErrorString(result)); \
} \
} while(0)

cudaGetLastError 返回当前错误状态,然后将其重置为 cudaSuccess

cudaPeekAtLastError 返回错误状态而不重置它

实际上,使用三重尖括号符号启动内核不会返回 cudaError_t

最好手动检查

vecAdd<<<blocks, threads>>>(devA, devB, devC);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());

cudaError_t 类型的值 cudaErrorNotReady(可能由 cudaStreamQuerycudaEventQuery 返回)不被视为错误,也不会被 cudaPeekAtLastErrorcudaGetLastError 报告。

CUDA_LOG_FILE=cudaLog.txt 的设置会让错误记录在日记中

$ env CUDA_LOG_FILE=cudaLog.txt ./test

Device and Host

对于函数

  • __global__ 说明内核函数入口点
  • __device__ 说明只能由内核函数调用
  • __hots__ 说明只能由 CPU 调用

可以同时使用 __device____host__

对于变量

  • __device__
  • __constant__
  • __managed__
  • __shared__
  • __device____global__ 函数内部声明且未使用说明符则优先分配寄存器

Cluster

块级同步

cluster.sync()

可访问分布式共享内存

函数声明

__global__ void __cluster_dims__(2, 1, 1) cluster_kernel(float *input, float* output)
{
}

编译宏

CUDA_ARCH

__host__ __device__
void foo()
{
#ifdef __CUDA_ARCH__
// GPU code
#else
// CPU code
#endif
}

GPU编程(python)

与常规 python 文件相比

需要添加包

from numba import cuda

在由 GPU 运行的函数前加上一段标识

@cuda.jit
def function(argv):
# ...

调用函数时使用 [,] 代替C++中的 <<<,>>>

一样具有索引

  • cuda.threadIdx.[xyz]
  • cuda.blockDim.[xyz]
  • cuda.blockIdx.[xyz]
  • cuda.gridDim.[xyz]

可以仿照在C++常见的计算线程索引的方式

idx = cuda.threadIdx.x+cuda.blockIdx.x*cuda.blockDim.x

也可以使用一个简写语法

idx = cuda.grid(1)