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 使用 blockIdxthreadIdx 来互相区分,这两个变量是 CUDA 的内置变量,为 uint3 类型,可以使用 x、y、z 三个字段进行指定,比如 blockIdx.xthreadIdx.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 = 10block.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

由上图也能看出,数据在全局内存中是线性存储的,所以可以通过 blockIdxthreadIdx 获取一个线程的唯一 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 = 0blockDim.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