CUDA 编程结构

在 GPU 上执行的函数称为 CUDA 核函数(Kernel Function),核函数会被 GPU 上多个线程执行。典型的 CUDA 程序遵循如下模式:

  1. 把数据从 CPU 内存(Host)拷贝至 GPU 内存(Device)
  2. 调用该 Kernel 函数,对 Device 中的数据进行操作
  3. 将数据从 Device 传送至 Host

CUDA 内存管理

  • GPU 内存分配:cudaMalloc
cudaError_t cudaMalloc(void** devPtr, size_t size)

函数返回一个 devPtr,指向所分配的内存。

  • Host 与 Device 之间的数据传输:cudaMemcpy
cudaError_t cudaMemcpy(void* dst, const void* src, size_t count, cudaMemcpyKind kind)

函数将 src 指向的数据传输至 dst,复制方向包含如下 4 种:

cudaMemcpyHostToHost = 0      // Host -> Host
cudaMemcpyHostToDevice = 1    // Host -> Device
cudaMemcpyDeviceToHost = 2    // Device -> Host
cudaMemcpyDeviceToDevice = 3  // Device -> Device

需要注意的是,这个函数以同步的方式执行,即 cudaMemcpy 完成操作并返回值前,程序是阻塞的。

此外,除了 Kernel 启动外,大部分 CUDA 运行时 API 函数均会返回一个错误枚举类型 cudaError_t。若 GPU 内存分配成功返回 cudaSuccess,否则返回 cudaErrorMemoryAllocation,可以通过 cudaGetErrorString(cudaError_t error) 得到可读的错误消息。

简单的数据传输示例

nvcc 中封装了多种编译工具。

#include <stdlib.h>
#include <string.h>
#include <time.h>

// A、B、C 都是数组,其本质是指向数组首元素的指针。
// 对于一个数组 A,A[i] 等价于 *(A + i)。
__global__ void sumArrayGPU(float *A, float *B, float *C, const int N){
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if(i < N){
        C[i] = A[i] + B[i];
    }
}

void sumArrayCPU(float *A, float *B, float *C, const int N){
    for(int i = 0; i < N; i++){
        C[i] = A[i] + B[i];
    }
}

void initData(float *ip, int size){
    time_t t;
    srand((unsigned int) time(&t));
    for(int i = 0; i < size; i++){
        ip[i] = (float)(rand() & 0xFF) / 10.0f;
    }
}

int main(int argc, char** argv){
    const int N = 1024;
    auto memSize = N * sizeof(float);
    float *h_A, *h_B, *h_C;
    h_A = (float *)malloc(memSize);
    h_B = (float *)malloc(memSize);
    h_C = (float *)malloc(memSize);
    initData(h_A, N);
    initData(h_B, N);
    sumArrayCPU(h_A, h_B, h_C, N);

    float *d_A, *d_B, *d_C;
    cudaMalloc((void**) &d_A, memSize);
    cudaMalloc((void**) &d_B, memSize);
    cudaMalloc((void**) &d_C, memSize);
    cudaMemcpy(d_A, h_A, memSize, cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, memSize, cudaMemcpyHostToDevice);
    sumArrayGPU<<<16, 16>>>(d_A, d_B, d_C, N);
    cudaMemcpy(h_C, d_C, memSize, cudaMemcpyDeviceToHost);

    free(h_A);
    free(h_B);
    free(h_C);
    cudaFree(d_A);
    cudaFree(d_B);
    cudaFree(d_C);
    return 0;
}

线程管理

由一个 Kernel 启动所产生的所有线程统称为一个 Grid,此 Grid 中所有线程共享全局内存。

一个 Grid 中包含多个 Block,一个 Block 又包含多个 Thread。不同 Block 内的 Thread 不能直接同步或访问对方的 SHM。 不过可以通过全局内存和原子操作等进行协作。

不同 Thread 使用 blockIdx 和 threadIdx 来互相区分,这两个变量是 CUDA 的内置变量,为 uint3 类型,可以使用 x、y、z 三个字段进行指定,比如 blockIdx.x、threadIdx.y。

Grid 和 Block 的维度依靠 dim3 类型的 gridDim 和 blockDim 来指定,这种类型的数据也可以通过 x、y、z 来获得。

CUDA 网格结构

在一段 cuda 程序中,会定义两组这种变量。一个是在 Host 中定义的 dim3 类型的 Grid 和 Block,该变量仅在 Host 中可见。

另一个是在 Kernel 函数中定义的 uint3 类型,仅在 Device 端可见。下面代码中会看到这两种变量定义位置的区别。

#include <cuda_runtime.h>
#include <stdio.h>

__global__ void checkDim(){
    // 内置的预初始化 Thread 和 Grid 变量,仅在 Device 访问
    printf("threadIdx:(%d,%d,%d) blockIdx:(%d,%d,%d) blockDim:(%d,%d,%d) "
           "gridDim:(%d,%d,%d)\n",
           threadIdx.x, threadIdx.y, threadIdx.z,
           blockIdx.x, blockIdx.y, blockIdx.z,
           blockDim.x, blockDim.y, blockDim.z,
           gridDim.x, gridDim.y, gridDim.z);
}

int main(int argc, char** argv){
    const int nElem = 6;
    // 手动定义的 dim3 类型的 Grid 和 Block Size,仅在 Host 访问
    dim3 block(3);
    dim3 grid((nElem + block.x - 1) / block.x);

    printf("grid.x %d grid.y %d grid.z %d\n", grid.x, grid.y, grid.z);
    printf("block.x %d block.y %d block.z %d\n", block.x, block.y, block.z);

    checkDim<<<grid, block>>>();
    cudaDeviceSynchronize();
    return 0;
}

在 dim3 grid((nElem + block.x - 1) / block.x); 处,+ block.x - 1 的原因很多人可能不懂,这是为了避免最后一个块不能完全包含一个 Block。

比如当 nElem = 10,block.x = 3,若不这样处理,10 / 3 = 3。3 个 Block,每个 Block 包含 3 个 Thread,这样一共处理 9 个任务,相比 10 少了 1 个;通过 + block.x - 1,(10 + 3 - 1) / 3 = 4,多出 1 个 Block 来处理第 10 个任务。简单来说,就是 向上取整。

调用 Kernel 函数

Kernel_Function<<<grid, block>>>(argument list);

<<<grid, block>>> 中便是 Kernel 函数的执行配置。

假设有 4 个 Block,每个 Block 中又有 8 个 Thread,那么核函数为: Kernel_Function<<<4, 8>>>(argument list);

threadIdx

由上图也能看出,数据在全局内存中是线性存储的,所以可以通过 blockIdx 和 threadIdx 获取一个线程的唯一 ID,同时建立线程和数据元素间的映射关系。一般情况下,可以通过 1D Grid 和 1D Block 来计算全局数据访问的唯一索引:int i = blockIdx.x * blockDim.x + threadIdx.x

Kernel 函数的调用与 Host 的线程是异步的,需要调用下面函数来强制 Host 等待所有 Kernel 函数执行结束:

cudaError_t cudaDeviceSynchronize(void);

此外,cudaMemcpy() 隐式地包含同步功能,当调用 cudaMemcpy 在 Host 和 Device 进行拷贝时,Host 线程将等待 cudaMemcpy 操作执行完毕才会继续执行程序。

Kernel 函数限定符

先提一嘴,Global Kernel 函数必须是 void 返回类型。

有三种限定符:

  • __global__:Host 调用,Device 执行
  • __device__:Device 调用,Device 执行
  • __host__:Host 调用,Host 执行(可省略)

当 __device__ 和 __host__ 一起使用时,函数可以同时在 Host 和 Device 上编译。

Kernel 函数和 C 函数的区别

__global__ void sumArrayGPU(float *A, float *B, float *C, const int N){
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if(i < N){
        C[i] = A[i] + B[i];
    }
}

从上面这段代码可以看出,for 循环消失了,因为内置的 Thread 坐标替代了原本的数组索引。

错误处理宏

#define CHECK(call)                                                    \
{                                                                      \
    const cudaError_t error = call;                                    \
    if (error != cudaSuccess)                                          \
    {                                                                  \
        printf("Error: %s:%d, ", __FILE__, __LINE__);                  \
        printf("code:%d, reason: %s\n", error, cudaGetErrorString(error)); \
        exit(1);                                                       \
    }                                                                  \
}

如果产生错误会报告一个错误代码,通过 cudaGetErrorString(error) 输出可读信息。


并行线程

由于矩阵用 行优先 的方式在全局内存中进行存储,对一个给定的线程,可以通过 Thread 和 Block 索引映射到矩阵坐标上:

ix = threadIdx.x + blockIdx.x * blockDim.x
iy = threadIdx.y + blockIdx.y * blockDim.y

然后将矩阵坐标映射到全局内存的索引上:

idx = iy * nx + ix

矩阵求和 - 2D Grid & 2D Block

使用 2D Grid 和 2D Block 计算矩阵加法的代码如下:

#include "common.h"

void SumMatCPU(float *A, float *B, float *C, const int nx, const int ny){
    float *ia = A;
    float *ib = B;
    float *ic = C;

    for(int iy = 0; iy < ny; iy++){
        for(int ix = 0; ix < nx; ix++){
            ic[ix] = ia[ix] + ib[ix];
        }
        ia += nx;
        ib += nx;
        ic += nx;
    }
}

__global__ void SumMatGPU2D(float *matA, float *matB, float *matC, int nx, int ny){
    unsigned int ix = threadIdx.x + blockDim.x * blockIdx.x;
    unsigned int iy = threadIdx.y + blockDim.y * blockIdx.y;
    unsigned int idx = ix + iy * nx;

    if(ix < nx && iy < ny){
        matC[idx] = matA[idx] + matB[idx];
    }
}

int main(int argv, char** argc){
    int dev = 0;
    cudaDeviceProp deviceProp;
    CHECK(cudaGetDeviceProperties(&deviceProp, dev));
    printf("Using Device %d : %s\n", dev, deviceProp.name);
    CHECK(cudaSetDevice(dev));

    int nx = 1 << 14; // 1 << 14 = 16384; 1 << 16 = 65536
    int ny = 1 << 14;
    int nxy = nx * ny;
    int memSize = nxy * sizeof(float);

    printf("Matrix size : %d, %d\n", nx, ny);

    // Malloc host mem
    float *h_A, *h_B, *hostRef, *gpuRef;
    h_A = (float *)malloc(memSize);
    h_B = (float *)malloc(memSize);
    hostRef = (float *)malloc(memSize);
    gpuRef = (float *)malloc(memSize);

    double start = seconds();
    initData(h_A, nxy);
    initData(h_B, nxy);
    double epls = seconds() - start;
    printf("Init Data eplased time: %f\n", epls);

    memset(hostRef, 0, memSize);
    memset(gpuRef, 0, memSize);
    start = seconds();
    SumMatCPU(h_A, h_B, hostRef, nx, ny);
    epls = seconds() - start;
    printf("Sum Mat on CPU cost : %f\n", epls);

    float *d_MatA, *d_MatB, *d_MatC;
    CHECK(cudaMalloc((void**)&d_MatA, memSize));
    CHECK(cudaMalloc((void**)&d_MatB, memSize));
    CHECK(cudaMalloc((void**)&d_MatC, memSize));

    cudaMemcpy(d_MatA, h_A, memSize, cudaMemcpyHostToDevice);
    cudaMemcpy(d_MatB, h_B, memSize, cudaMemcpyHostToDevice);

    int dimX = 32;
    int dimY = 32;
    dim3 block(dimX, dimY);
    dim3 grid((nx + block.x - 1) / block.x, (ny + block.y - 1) / block.y);

    start = seconds();
    SumMatGPU2D<<<grid, block>>>(d_MatA, d_MatB, d_MatC, nx, ny);
    cudaDeviceSynchronize();
    epls = seconds() - start;
    printf("SumMatGPU2D cost time : %f\n", epls);
    CHECK(cudaGetLastError());

    CHECK(cudaMemcpy(gpuRef, d_MatC, memSize, cudaMemcpyDeviceToHost));

    checkResult(hostRef, gpuRef, nxy);
    cudaFree(d_MatA);
    cudaFree(d_MatB);
    cudaFree(d_MatC);
    free(h_A);
    free(h_B);
    free(hostRef);
    free(gpuRef);

    cudaDeviceReset();
    return 0;
}

矩阵求和 - 1D Grid & 1D Block

对于一个 Size 为 ​\text {(nx, ny)} 的矩阵,使用 1D Grid 和 1D Block 意味着每个 Thread 要处理 ​\text {ny} 个数据,相比上面 2D Grid 和 2D Block 的情况,全局索引映射会有很大不同。由于只有 threadIdx.x 有用,因此还需要一个 for 循环来处理每个线程中的 ​\text {ny} 个元素:

__global__ void sumMatrixOnGPU1D(float *MatA, float *MatB, float *MatC, int nx, int ny){
    unsigned int ix = threadIdx.x + blockIdx.x * blockDim.x;
    if(ix < nx){
        for(int iy = 0; iy < ny; iy++){
            int idx = iy * nx + ix;
            MatC[idx] = MatA[idx] + MatB[idx];
        }
    }
}

矩阵求和 - 2D Grid & 1D Block

在这种情况下,由于 Block 是 1D 的,threadIdx.y = 0 且 blockDim.y = 1,行号由 blockIdx.y 决定,因此 Kernel 函数可写为:

// grid 2D block 1D
__global__ void sumMatrixOnGPUMix(float *MatA, float *MatB, float *MatC, int nx, int ny){
    unsigned int ix = threadIdx.x + blockIdx.x * blockDim.x;
    unsigned int iy = blockIdx.y;
    unsigned int idx = iy * nx + ix;

    if(ix < nx && iy < ny)
        MatC[idx] = MatA[idx] + MatB[idx];
}

参考文章

[1] CUDA 官方文档:CUDA 编程模型(中文)

[2] CUDA Runtime API Documentation