简介

CUDA 是 NVIDIA 开发的、用于 GPU 计算的库。相比 CPU 一个控制器对应一个 ALU 而言,GPU 中的一个控制器对应一组 ALU。因此 GPU 不适合完成逻辑复杂的计算,但是也因此具备了对大量数据的计算能力。GPU 开线程的开销更低,计算单元更多,并行计算能力更强。

首先需要注意的是 GPU 和 CPU 是分开的。GPU 相关的代码在 GPU 上运行,CPU 相关的代码在 CPU 上运行,两者的数据交换需要通过 PCIe 总线进行的。

回想在编写汇编的时候,控制外设需要特殊的指令,需要往寄存器写入特殊的值来完成对外设的控制。那么理解控制 GPU 的原理也就不难了。不同的是外设 会将自己的内存映射到主机内存,通过对主机的内存的操作就完成了对外设内存的操作。

CUDA 6.0 之后也支持类似的统一寻址

CUDA 语言和编译器

CUDA 语言本身是对 C/C 语法的一种扩充。C 代码运行在 CPU 上,而 CUDA 扩充的部分运行在 GPU 上。CUDA 的编译器 nvcc 在编译期间将 GPU 代码进行翻译,然后再用原生的 C/C++ 编译器完成剩余部分。

CUDA 文件的后缀名为 cu/cuh

下面是一个 hello, world 的例子:

#include <stdio.h>
#include <iostream>
using namespace std;

__global__ void hello_world() {
    printf("Hello world\n");
}

int main(int argc, char** argv) {
    hello_world<<<1, 1>>>();
    cudaDeviceSynchronize();
    return 0;
}

这里需要注意的是,能运行在 GPU 上的代码是有限的。例如 std::cout 是没办法运行在 GPU 上的。

GPU 内存控制

在 GPU 上管理内存、GPU 和 CPU 内存数据交换需要特殊的函数:

标准C函数

CUDA C 函数

说明

malloc

cudaMalloc

内存分配

memcpy

cudaMemcpy

内存复制

memset

cudaMemset

内存设置

free

cudaFree

释放内存

核函数

核函数的声明和普通函数类似,但是有以下限制:

  • 返回值必为 void

  • 只能访问设备内存

  • 不支持可变参数

  • 不支持静态变量

  • 必定为异步函数

  • 被 __global__ 修饰。

cuda 中的函数可以被以下修饰符之一进行修饰:

限定符

执行

调用

__global__

设备侧

主机侧

__device__

设备侧

设备侧

__host__

主机侧

主机侧

核函数调用的形式如下:

kernel_name<<<Dg, Db>>>(args);

尖括号中的内容被称为 执行配置 ,执行配置一共有四个,但是这里只讲两个。

Dg (Dimension of Grid) 代表了网格的维度,也就是块的个数。Db (Dimension of Block) 指明了 block 的维度,也就是说线程的数目。两者相乘就是此核函数可用的线程数量。

为了确定当前线程的位置,cuda 设置了几个内置变量:

变量

作用

gridDim

所在网格的维度,一个 Grid 中有多少个 Block

blockDim

所在块的维度,一个 Block 中有几个 Thread

blockIdx

当前块在网格中的索引

threadIdx

当前线程在所处块的索引

cuda 中资源的数量是有限的。例如对于于 Fermi 设备,每个块的最大线程数是 1024,且网格的 x、y、z 三个方向上的维度最大值是65535。

同一个块中的线程之间可以相互协作,不同块内的线程不能协作

所有 cuda 核函数的调用是异步的,但是可以用以下函数阻塞至所有核函数执行完毕:

cudaError_t CUDARTAPI cudaDeviceSynchronize(void);

另外,当执行 cudaMemcpy 时,host 和 device 会自动进行同步。

在核函数中调用核函数

内存层次

CUDA 将内存层次从高到底分为 Grid、Block 和 Thread。一个核函数启动一个 Grid,一个 Grid 中包含多个 Block,一个 Block 中包含多个 Thread

Grid 的数量最多为一个,Grid 的维度最高为 2,Block 的维度最高为 3。尽管 Grid 的维度在核函数的配置中也使用 Dim3 数据类型,但是最后一维的维度为 1。

dim3 数据类型

dim3 数据类型是 cuda 新增的数据类型,用来指定核函数的维度。一个 dim3 数据类型可以用来指定三个维度上的值,每个维度的值都必须是正整数。例如:

可以使用 dim3 来对核函数进行配置,例如:

dim3 gridDim(2,2);
dim3 blockDim(1,2,3);
sumVector<<<gridDim, blockDim>>>(A, B, C);

向量加法

下面是一个使用 cuda 计算的向量加法:

#include <strings.h>
#include <iostream>
#include <random>
using namespace std;

__global__ void calc(float const* a, float const* b, float* c) {
    unsigned i = threadIdx.x;
    c[i]       = a[i] + b[i];
}

void initData(float* vec, size_t size) {
    static std::random_device             rd;
    static std::uniform_real_distribution<float> dist(0, 1000.f);

    for(int i = 0; i < size; ++i) vec[i] = dist(rd);
}

#define CHECK_CUDA(err)                  \
    {                                        \
        if((err) != cudaSuccess) {           \
            cout << cudaGetErrorString(err); \
            exit(1);                         \
        }                                    \
    }

int main(int argc, char** argv) {
    unsigned size   = 10;
    unsigned nBytes = size * sizeof(float);
    float*   h_A    = new float[size];
    float*   h_B    = new float[size];
    float*   h_C    = new float[size];

    initData(h_A, size);
    initData(h_B, size);
    bzero(h_C, nBytes);
    // 设置 device
    float *d_A, *d_B, *d_C;
    CHECK_CUDA(cudaMalloc((float**)&d_A, nBytes));
    CHECK_CUDA(cudaMalloc((float**)&d_B, nBytes));
    CHECK_CUDA(cudaMalloc((float**)&d_C, nBytes));

    cudaMemcpy(d_A, h_A, nBytes, cudaMemcpyKind::cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, nBytes, cudaMemcpyKind::cudaMemcpyHostToDevice);

    calc<<<1, 10>>>(d_A, d_B, d_C);

    cudaMemcpy(h_C, d_C, nBytes, cudaMemcpyKind::cudaMemcpyDeviceToHost);
    cudaFree(d_A);
    cudaFree(d_B);
    cudaFree(d_C);

    for(int i = 0; i < size; ++i) {
        std::cout << h_C[i] << ' ';
    }

    cudaDeviceReset();
    return 0;
}

设备信息

nvidia 提供了一个命令行工具 nvidia-smi 来查询显卡相关的信息,此命令行工具一般随 软件包 nvidia-utils 附带。

nvidia-smi 从 0 到 N-1 对设备进行标记,使用环境变量 CUDA_VISIBLE_DEVICES 可以影响程序所能使用的 GPU。多个 GPU 中间使用逗号进行分隔。

cuda 可以通过以下函数查询系统中的设备信息:

cudaError cudaGetDeviceProperties(cudaDeviceProp *prop, int device)

例如:

int main(int argc, char** argv) {
    int  deviceCount;
    auto err = cudaGetDeviceCount(&deviceCount);
    HANDLE_ERROR(err);
    if(!deviceCount) {
        cout << "没有可用于 cuda 的设备" << endl;
        return -1;
    }

    cudaDeviceProp props;
    for(int i = 0; i < deviceCount; ++i) {
        cudaGetDeviceProperties(&props, i);
        cout << "设备名:" << props.name << endl;
        cout << "设备内存存:" << props.totalGlobalMem / 1024 / 1024 << "MB" << endl;
        cout << "设备可用共享内存:" << props.sharedMemPerBlock / 1024 / 1024 << "MB" << endl;
        if(props.integrated) cout << "设备是集成显卡";
    }

    return 0;
}

在多 GPU 系统中根据选择处理器最多的 GPU:

int getBestDevice() {
    int  deviceCount;
    auto err = cudaGetDeviceCount(&deviceCount);
    HANDLE_ERROR(err);

    int            bestDevice         = -1;
    int            maxMultiProcessors = 0;
    cudaDeviceProp prop;
    for(int i = 0; i < deviceCount; ++i) {
        err = cudaGetDeviceProperties(&prop, i);
        HANDLE_ERROR(err);

        if(prop.multiProcessorCount > maxMultiProcessors) {
            maxMultiProcessors = prop.multiProcessorCount;
            bestDevice         = i;
        }
    }
    return bestDevice;
}

GPU 还可以根据需要查找需要的 GPU。例如,查找版本号 最接近 1.3 的 GPU:

int getGPU() {
    int            device;
    cudaDeviceProp props;

    memset(&props, 0, sizeof(props));
    props.major = 1;
    props.minor = 3;

    cudaError_t err = cudaChooseDevice(&device, &props);
    HANDLE_ERROR(err);
    cout << "设备 " << device << " 的版本号最接近 1.3" << endl;

    return device;
}

向上取整

这里介绍一个向上取整的例子,这个在 cuda 中用的比较多,也没必要使用库函数

假设现在有值 x 除 10 向上取整,则计算方式为 (x+9)/10

线程索引

在上面已经知道,一个核函数有一个 Grid, 一个 Grid 有若干个 Block,每个 Block 有若干 Thread。不同 Block 中 Thread 的索引是重新计算的。那么我们如何确定线程在 Grid 中的索引呢?

参考

一维索引

将 Grid 中的线程线性排列起来,然后进行编号,就得到了线程的索引。编号方式如下图:

Diagram

可以看到上述的矩阵是这样的一个存在:

usigned matrix[blockDim.x][GridDim.x];
int item = matrix[threadIdx.x][blockIdx.x];

将其投射到一维上,就得到了:

int idx = threadIdx.x + blockIdx.x * blockDim.x;

这里 idx 就是一维情况下线程的索引

另外需要知道的是由于 cuda 有维度的概念,因此准确来说 idx 是 cuda 在 x 维度上线程的一维索引,同理可以得到 y 维度上的索引:

int idy = threadIdx.y + blockIdx.y * blockDim.y;

二维索引

TODO

threadIdx.y * blockDim.x + threadIdx.x

三维索引

TODO

threadIdx.z * blockDim.y * blockDim.x + threadIdx.y * blockDim.x + threadIdx.x

矩阵加法

矩阵的存储方式可以简单的使用二维数组实现。在 C 语言中,访问一个二维数组的方式很简单:

float A[M][N];
for (int i = 0; i < M; ++i) {
   for (int j = 0; j < N; ++j) {
        A[i][j] = ...;
   }
}

但是这里要明白二维数组的存储方式是线性的,也就是说对于每个矩阵中的元素而言,其坐标 (i, j) = i * N + j。也就是说,我们要访问 (i, j),只需要访问 A[i * N + j] 即可。

下面给出了矩阵加法的实现例子:

二维网格的线程加法

从上面的分析我们可以知道矩阵线性排列时的坐标为 i * N + j,而从 一维索引 我们可以可以得到 i 和 j 的值分别为:

int i = threadIdx.x + blockIdx.x * blockDim.x;
int j = threadIdx.y + blockIdx.y * blockDim.y;

而 N 是矩阵的宽度,是由核函数的参数给出的,由此我们可以得到一个矩阵求和的核函数:

__global__ void sumMatrix2D(float* a, float * b, float* result,int M, int N){
   unsigned x = threadIdx.x + blockIdx.x * blockDim.x;
   unsigned y = threadIdx.y + blockIdx.y * blockDim.y;
   int idx = x + y * M;

   if(x<M && y<N){
       result[idx] = a[idx] + b[idx];
   }
}

使用 dim3 数据类型对其进行调用:

int main(int argc, char** argv) {
    float a[2][2] = { { 1, 2 }, { 3, 4 } };
    float b[2][2] = { { 5, 6 }, { 7, 8 } };
    float c[2][2];
    int   nBytes = 4 * sizeof(float);

    float *d_a, *d_b, *d_c;
    cudaMalloc((void**)&d_a, nBytes);
    cudaMalloc((void**)&d_b, nBytes);
    cudaMalloc((void**)&d_c, nBytes);
    cudaMemcpy(d_a, a, nBytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, b, nBytes, cudaMemcpyHostToDevice);

    dim3 blockDim(2, 2);
    dim3 gridDim(2, 2);

    sumMatrix2D<<<gridDim, blockDim>>>(d_a, d_b, d_c, 2, 2);
    cudaDeviceSynchronize();
    cudaMemcpy(c, d_c, nBytes, cudaMemcpyDeviceToHost);

    for(int i = 0; i < 2; ++i) {
        for(int j = 0; j < 2; ++j) { printf("%f ", c[i][j]); }
        printf("\n");
    }
    cudaFree(d_a);
    cudaFree(d_b);
    cudaFree(d_c);
    cudaMemcpy(d_a, a, 4, cudaMemcpyHostToDevice);
    cudaDeviceReset();
    return 0;
}

CUDA 执行模型

硬件结构

CUDA 围绕一个称为 SM (流式多处理器) 的可拓展阵列搭建,通过复制这种架构的构建块实现硬件并发。GPU 中的 SM 具有以下特点:

  • 一个 SM 能支持数百线程的并发

  • 一个 GPU 包含多个 SM

  • 一个 SM 可以执行多个线程块

  • 一个线程块的线程只会在同一个 SM 上执行

  • GPU 通过 SM 资源的可用性调度线程块

当网格被启动后,首先将线程块调度到 SM 上,然后 SM 将块中线程按 32 个一组划分为线程束。

如果块中线程无法被 32 整除,则最后一个线程束数量少于 32,而且这些最后这个线程数虽然不活跃,却仍站需要分配寄存器等资源

CUDA 采用 SIMT (单指令多线程) 的方式管理和执行线程。具体做法是让 线程束中的所有线程同时执行相同的指令 。但是以下两点不同:

  • 线程的寄存器状态不同

  • 线程间的部分数据是共享的

SM 将分配给自己的线程块划分为线程束,然后在可用硬件上进行执行。

这里的 执行相同的指令 指得是 PC 寄存器中的值相同。每个周期每个线程束中 PC 寄存器中的指令相同。如果不同会导致 线程束分化

尽管线程块里的所有线程都可以逻辑地并行运行,但是并不是所有线程都可以同时在物理层面执行。因此,线程块里的不同线程可能会以不同的速度前进。

在并行线程中共享数据可能会引起竞争:多个线程使用未定义的顺序访问同一个数据,从而导致不可预测的程序行为。CUDA 提供了一种用来同步线程块里的线程的方法,从而保证所有线程在进一步动作之前都达到执行过程中的一个特定点。然而,没有提供块间同步的原语。

SM 是 GPU 架构的核心。寄存器和共享内存是 SM 中的稀缺资源。 CUDA 将这些资源分配到 SM 中的所有常驻线程里。

CUDA 内存模型

CUDA 提供了不同的内存层次,对于一个线程而言,可以访问的有:

  • 线程自己的寄存器

  • 线程自己的本地内存

  • Block 的共享内存

  • Grid 中的全局内存

  • Grid 中的常量内存

  • Grid 中的纹理内存

其中,常量内存和纹理内存是只读的。

寄存器的数量是十分有限的,在 Fermi GPU 中每个线程只有 63 个寄存器,而 Kepler GPU 拓展到了 255 个寄存器。使用寄存器少的核函数在 SM 上有更多的常驻线程块,而每个 SM 上并发线程块越多,使用率和性能越高。

如果寄存器不够用,就会使用本地内存代替多余的寄存器,这显然不利于程序性能。

使用启动边界可以帮助编译器对核函数进行优化:

__global__ void __launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor) kernel(){}

其中:

  • maxThreadsPerBlock 是每个块能使用的最大线程数

  • minBlocksPerMultiprocessor 是可选参数,指明了每个多线程单元能使用的最小块数

在核函数中使用 shared 修饰的变量位于共享内存中。共享内存是线程通信的基本方式。使用下述函数可以设立一个屏障,会将所有线程阻塞到此点:

void __syncthreads();

使用 constant 修饰的变量位于常量内存中。常量内存中的变量在主机端声明和初始化,核函数中只有访问权。初始化函数为:

cudaError_t cudaMemcpyToSymbol(const char *symbol, const void *src, size_t count, size_t offset, cudaMemcpyKind kind);

全局内存就是前面使用 cudaMalloc 分配的内存

内存管理

cuda 使用四个函数来管理全局内存:

  • cudaMalloc

  • cudaMemcpy

  • cudaMemset

  • cudaMemFree

另外,在主机上分配内存时可以会由于分页机制导致实际物理页被移动,这对于 GPU 来说是不安全的,因此在从主机内存往 GPU 传输数据时,首先分配固定内存,然后将数据拷贝到固定内存中,之后再从固定内存传输到 GPU 内存。

cuda 可以使用以下函数来在主机上分配固定内存:

cudaError_t cudaMallocHost(void **ptr, size_t size);
cudaError_t cudaFreeHost(void *ptr);

显然,固定内存分配太多会影响主机性能。对于数据量超过 10MB 的数据而言,使用固定内存性能更好

零拷贝内存

cuda 可以将主机上的一部分 固定内存 映射到设备中,从而带来更好的性能和更便捷的操作。对于零拷贝内存而言,可以做到:

  • 设备内存不足时使用主机内存

  • 避免主机和设备间的显式数据传输

  • 提高数据传输率

零拷贝内存相关的 API 是:

cudaError_t cudaHostAlloc(void **devPtr, size_t size, unsigned int flags);
cudaError_t cudaHostGetDevicePointer(void **pDevice, void *pHost, unsigned int flags);

第一个函数用来分配固定内存,第二个函数用来获取 pHost 指针在 pDevice 上对应的指针。

下面是一个例子:

int main(int argc, char** argv) {
    int    nBytes = 4 * sizeof(float);
    float *h_a, *h_b, *h_c;
    cudaHostAlloc(&h_a, nBytes, cudaHostAllocMapped);
    cudaHostAlloc(&h_b, nBytes, cudaHostAllocMapped);
    cudaHostAlloc(&h_c, nBytes, cudaHostAllocMapped);

    initData(h_a, 4);
    initData(h_b, 4);

    float *d_a, *d_b, *d_c;
    cudaHostGetDevicePointer(&d_a, h_a, 0);
    cudaHostGetDevicePointer(&d_b, h_b, 0);
    cudaHostGetDevicePointer(&d_c, h_c, 0);

    dim3 blockDim(2, 2);
    dim3 gridDim(2, 2);

    sumMatrix2D<<<gridDim, blockDim>>>(d_a, d_b, d_c, 2, 2);
    cudaDeviceSynchronize();
    // cudaMemcpy(h_c, d_c, nBytes, cudaMemcpyDeviceToHost);

    for(int i = 0; i < 4; ++i) {
        printf("%f\n", h_c[i]);
        printf("\n");
    }
    cudaFreeHost(h_a);
    cudaFreeHost(h_b);
    cudaFreeHost(h_c);
    cudaDeviceReset();
    return 0;
}
在上面我们看到尽管零拷贝内存是一种非常方便的方式但是由于同一块内存在主机和设备上的地址不同
导致既要获得主机指针又要获得设备指针十分麻烦 cuda 4.0  计算能力在 2.0 及其以上的设备中
支持 :abbr:`UVA (统一虚拟寻址)` 这会让设备指针和主机指针的值相等进而简化操作

统一内存寻址

相比 UVA 而言,cuda 6.0 引入了“统一内存寻址”这一特性。相比 UVA,统一内存寻址特点为:

  • 数据分别储存在主机和设备上的内存中,主机和设备上的内存地址相同

  • 可以通过一个统一的内存地址来访问主机和设备上的内存

  • 数据根据需要会自动复制到设备上

要使用 UVA,可以使用 managed 关键字,被此关键字修饰的内存地址同时在主机和设备上可用。可可以使用 cudaMallocManaged() 分配内存。

网格和线程块大小的准则

使用这些准则可以使应用程序适用于当前和将来的设备:

  • 保持每个块中线程数量是线程束大小(32)的倍数

  • 避免块太小:每个块至少要有 128 或 256 个线程

  • 根据内核资源的需求调整块大小

  • 块的数量要远远多于 SM 的数量,从而在设备中可以显示有足够的并行

  • 通过实验得到最佳执行配置和资源使用情况

同步

cuda 提供了两个函数分别用于主机-设备同步和线程之间的同步。

cudaError_t cudaDeviceSynchronize(void);
__device__ void _syncthreads(void); // 等待线程块中的其它线程达到此点

不同块中的线程不允许相互同步,因此GPU可以以任意顺序执行块。这使得 CUDA 程序在大规模并行GPU上是可扩展的

内存访问模式

TODO: 需要确认 对全局内存的的访问都需要经过两级缓存,首先访问一级缓存,命中丢失则访问二级缓存,再丢失则由 DRAM 直接访问。

一级缓存可以由两个编译器标志控制:

标志

作用

-Xptxas -dlcm-cg

禁用一级缓存

-Xptxas -dlcm=ca

启用一级缓存

如果启用一级缓存,则内存事务大小为 128 字节,否则为 32 字节。由此引出第一个内存访问模型:

  • 将多个线程的数据请求合并到一个事务中

当设备内存事务的首地址是缓存粒度的偶数倍时(二级缓存 32 字节、两级缓存 128 字节),就会出现对齐内存访问。

在 Kepler K10、K20 和 K20X GPU 中,一级缓存不用来缓存全局内存加载。一级缓存专门用于缓存寄存器溢出到本地内存中的数据

数组结构体

有两种存储大量数据的方式: SoA (结构体数组)AoS (数组结构体)

struct innerStruct {
   float x;
   float yl
}
innerStruct AoS[N];

struct innerArray {
   float x[N];
   float y[N];
}

如果访问时批量访问 x 数据而不访问 y 数据,那么第一种声明是 CPU 利好的,而第二种是 GPU 利好的。

第二中情况下 x 数据是相邻排列的,对 GPU 数据的加载非常有利。

性能调整

此外,还可以通过以下方式对性能进行优化:

  • 循环展开

  • 增大并行性

  • 最大利用带宽

线程束分化

每个指令周期,线程束中的线程执行的命令都是一样的。线程束中的所有线程都执行 if 和 else 部分,但是符合条件的线程进入条件语句,不符合条件的线程原地等待。这种限制是由于 GPU 线程是批量管理的,一个调度器匹配一个线程束。没法更精确地划分。

当线程束出现分化的时候带来的性能下降非常严重,因此在出现 CUDA 中任务分配地基本单位应当是 32

由于在一个线程块中,线程的线程束分配是确定的,因此,可以使用以下函数来避免线程束分化:

int tid = blockIdx.x * blockDim.x + threadIdx.x;
int partion = (tid / wrapSize) % 2;
if (partion) {
    //...
}
else {
    //...
}

这里的 wrapSize 是线程分束大小,一般为 32

cuda 会将一些短的、有条件的代码段的断定指令取代了分支指令。在分支预测中,根据条件,把每个线程中的一个断定变量设置为 1 或 0。这两种条件流路径被完全执行,但只有断定为1的指令被执行。断定为 0 的指令不被执行,但 相应的线程也不会停止

避免分支优化

并行归约

__global__ void reduceBeughbored(int* g_idata, int* g_odata, unsigned int n) {
   unsigned tid = threadIdx.x;
   int* idata = g_idata + blockIdx.x * blockDim.x;
   int idx = threadIdx.x + blockDim.x * threadIdx.y; // ? 这一行代码对吗?
   if (idx > n) return;

   for (int stride = 1; stride < blockDim.x; stride *= 2) {
      if ((tid % (2 * stride)) == 0) idata[tid] += idata[tid + stride];
      __syncthreads();
   }
   if (tid == 0) g_odata[blockIdx.x] = idata[0];
}

这里需要注意 if tid % (2 * stride == 0) 会带来很高的分支优化,可以将此循环优化为:、

for (int stride = 1; stride < blockDim.x; stride *= 2) {
   int index = 2 * stride * tid;
   if (index < blockDim.x)
      idata[index] += idata[tid + stride];
   __syncthreads();
}

循环展开

只剩下32个或更少线程(即一个线程束)的情况。因为线程束的执行是SIMT(单指令多线程)的,每条指令之后有隐式的线程束内同步过程。因此,归约循环的最后6个迭代可以用下述语句来展开:

if (tid < 32) {
   volatile int* vmem = idata;
   vmem[tid] += vmem[tid + 32];
   vmem[tid] += vmem[tid + 16];
   vmem[tid] += vmem[tid + 8];
   vmem[tid] += vmem[tid + 4];
   vmem[tid] += vmem[tid + 2];
   vmem[tid] += vmem[tid + 1];
}

注意变量vmem是和volatile修饰符一起被声明的,它告诉编译器每次赋值时必须将vmem[tid]的值存回全局内存中。如果省略了volatile修饰符,这段代码将不能正常工作,因为编译器或缓存可能对全局或共享内存优化读写

更进一步地,可以将整个循环完全展开。中间可以使用一些模板的技巧

资源分配

同一时刻能在 SM 上执行的线程是有限的,线程束一旦被调度到 SM 上,就不会离开 SM 直至运行结束。线程束都需要分配一些资源,线程束需求的资源越多,SM 上能同时执行的线程越少。SM 上能有多少线程束处于激活状态,取决于三种资源:

  • 程序计数器

  • 寄存器

  • 共享内存

如果每个 SM 没有足够的寄存器或共享内存去处理至少一个块,那么内核将无法启动。一些关键限制如下:

技术条件

计算能力

2.0

2.1

3.0

3.5

每个线程块的最大线程数

1021

每个多处理器并发线程块的最大数量

8

16

每个多处理器并发线程束的最大数量

48

64

每个多处理器并发线程的最大数量

1536

2048

每个多处理器中 32 位寄存器数量

32KB

64KB

每个线程中 32 位寄存器的最大数量

63

255

每个多处理器中共享内存的最大数量

48KB

获得了足够资源的线程束是活跃的线程束,活跃的线程束分为三种:

  • 选定的线程束

  • 阻塞的线程束

  • 符合条件的线程束

SM 要执行某个线程束的时候,这个线程束叫选定的线程束,准备要执行的是符合条件的线程束,线程束不符合条件无法启动的叫阻塞的线程束。线程束需要满足两个条件:

  • 32 个 CUDA 核心可用于执行

  • 执行所需要的资源全部就位

cuda 为了隐藏由线程束阻塞造成的延迟,需要让大量的线程束保持活跃。

延迟隐藏

GPU 发指令到指令被执行这段时间称为指令延迟,指令分为两种,其延迟如下:

  • 算术指令:10~20个周期

  • 内存指令:400~800个周期

当 GPU 中有足够多的活跃线程束时,整个 GPU 对外看来就没有延迟。所需的活跃线程束数量为:

所需线程束数量=延迟 x 吞吐量

假设在内核里一条指令的平均延迟是5个周期。为了保持在每个周期内执行6个线程束的吞吐量,则至少需要30个未完成的线程束

GPU 加速库和 OpenACC

NVIDIA 社区提供了一系列 GPU 库供用户使用,这些库可以在 NVIDIA CUDA-X | NVIDIA Developer 中发现。就目前而言,提供以下几种库:

作用

cuBLAS

线性代数库

cuFFT

快速傅立叶变换

CUDA Math Library

数学库

cuRAND

随机数生成

cuSOLVER

密集和稀疏直接求解器

cuSPARSE

用于稀疏矩阵的线性代数库

cuTENSOR

传感器线性代数库

AmgX

核心求解

Thrust

C++并行算法和数据结构

cub

CUDA C++ 的协作原语

还有一些其它的库未列出。

cuda 库的流程一般而言遵循以下顺序:

  1. 在库中创建一个句柄

  2. 为输入输出分配 设备内存

  3. 转换数据格式到库函数规定的数据格式

  4. 将数据填充到设备内存中

  5. 配置要执行的库运算

  6. 库函数调用

  7. 取得计算结果

  8. 释放 cuda 资源

安装 cuda 的时候就附带了这些库,不需要手动下载。

交错规约

例子如下,这里删掉错误处理代码:

// 线程的数量
const size_t THREADS = 5;

/**
 * @param a 输入数据集合
 * @param b 输出数据集合
 * @param size a 的长度,要求为 THREADS 的整数倍
 * b 的长度为 THREADS
 */
__global__ void reduced(int *a, size_t size) {
    int idx = threadIdx.x + blockDim.x * blockIdx.x;
    if(idx > size) return;

    for(int i = THREADS; i < size; i += THREADS) a[idx] = a[idx] + a[idx + i];
}

int computeByCPU(int *arr, size_t size) {
    int res = 0;
    for(int i = 0; i < size; ++i) { res += arr[i]; }
    return res;
}

int main() {
    size_t LENGTH       = THREADS * 200;
    size_t LENGTH_BYTES = LENGTH * sizeof(int);

    int a[LENGTH];
    initArray(a, LENGTH);

    int cpuRes = computeByCPU(a, LENGTH);

    int *d_a;
    auto err = cudaMalloc(&d_a, LENGTH_BYTES);

    err = cudaMemcpy(d_a, a, LENGTH_BYTES, cudaMemcpyHostToDevice);

    reduced<<<1, THREADS>>>(d_a, LENGTH);

    err = cudaDeviceSynchronize();
    err = cudaMemcpy(a, d_a, LENGTH_BYTES, cudaMemcpyDeviceToHost);

    int gpuRes = computeByCPU(a, THREADS);
    cout << "CPU result: " << cpuRes << "\nGPU result: " << gpuRes << endl;

    err = cudaDeviceReset();
    return 0;
}

在执行上面的函数时有几点要求:

  1. 数组的长度必须能被 THREADS 整除,这样才没有遗漏的数据

并行规约

注意,这里面的 comm 文件内容为:

#pragma once

#include <chrono>
#include <iostream>

using namespace std;

inline std::chrono::system_clock::time_point currentTime() {
  return std::chrono::system_clock::now();
}

inline double elapsedTime(std::chrono::system_clock::time_point const &start,
                          std::chrono::system_clock::time_point const &end =
                              chrono::system_clock::now()) {
  return static_cast<chrono::duration<double>>(end - start).count();
}

template <typename T> inline void initArrar(T *arr, size_t size) {
  for (int i = 0; i < size; ++i)
    arr[i] = i;
}

template <typename T> inline T computeSingle(T *arr, size_t size) {
  T result = 0;
  for (int i = 0; i < size; ++i) {
    result += arr[i];
  }
  return result;
}

struct Dim3 {
  const size_t x;
  const size_t y;
  const size_t z;

  Dim3(const size_t x = 1, const size_t y = 1, const size_t z = 1)
      : x(x), y(y), z(z) {}
};

现在假设有一个数组,其下标范围为 0..n,现在使用 m 个线程对其进行求和

将这 n 个数据进行分块,则每块的带下 b = (n/m);

在这 m 个线程中,将工作划分如下:

线程 ID

处理元素

0

0~b

1

b~2b

2

2b~3b

…​

…​

m-1

(m-1)b~n

每个线程都将计算的结果储存到结果集 part_sum 中,则算法如下:

这里面 gridDim.x 代表了 m,size 代表了 n

#include "comm.h"
#include <memory>
#include <omp.h>

const Dim3 gridDim { 12 };
const Dim3 blockDim { 6000000 };

int twoPass(const float* input, size_t size)
{
    float* part_sum = new float[gridDim.x];
    memset(part_sum, 0, sizeof(float) * gridDim.x);
    // 第一步
    // 将 input 中的数据归约到 part_sum
    // [0: size] --> [0: gridDim.x]
    omp_set_num_threads(gridDim.x);
#pragma omp parallel
    {
        int idx = omp_get_thread_num();
        int startIdx = idx * gridDim.x;
        int endIdx = (idx + 1) * gridDim.x;
        endIdx = endIdx > size ? size : endIdx; // 元素不足
        endIdx = idx == (gridDim.x - 1) ? size : endIdx; // 元素多余

        for (; startIdx < endIdx; ++startIdx) {
            part_sum[idx] += input[startIdx];
        }
    }
    // 第二步
    // 将 [0: gridDim.x] --> sum
    for (int i = 1; i < gridDim.x; ++i) {
        part_sum[0] += part_sum[i];
    }
    int res = part_sum[0];
    delete[] part_sum;
    return res;
}

int main(int argc, char** argv)
{
    const size_t DATA_LEN = gridDim.x * blockDim.x + 20;
    float* data = new float[DATA_LEN];
    initArrar(data, DATA_LEN);

    auto start = currentTime();
    int cpuRes = computeSingle(data, DATA_LEN);
    auto point1 = currentTime();
    cout << "cpu 计算消耗的时间为: " << elapsedTime(start, point1) << endl;
    int paralRes = twoPass(data, DATA_LEN);
    delete[] data;
    cout << "parallel 计算消耗的时间为: " << elapsedTime(point1) << endl;
    cout << "cpuRes: " << cpuRes << endl
         << "paralRes: " << paralRes << endl;
    return 0;
}

扫描算法

现在假设输入一个数组 $input[i]$ 计算新数组 $output[i]$ ,计算方式为:

output[i] = input[0] + input[1] + input[2] + \cdots + input[i]

TODO

核函数的嵌套调用

OpenMP

OpenMP 的执行模式是 fork-join 模式。简单的来讲,OpenMP 在碰到并行块的时候根据配置 fork 子进程,在并行块结束的时候将所有的子进程进行 join。

omp 语句模式为:

#pragma omp 指令 子句 子句...

指令可以单独出现,但是子句必须出现在指令之后(或者出现在指令块中)

指令

子句

section

把不同的区域分给不同的线程去执行

single

代码块只需要一个线程执行

critical

代码块每次只有一个线程能进入

flush

进行线程同步

barrier

将所有线程阻塞到当前点后在向下走

aotmic

一个数据操作需要原子性

master

一段代码由主线程执行

thread private

用来指定一个或多个变量是线程专有

常见的子句有:

子句

作用

num_threads (n)

设置执行并行块的线程数

for

对接下来的 for 语句进行多线程优化

schedule(type, size)

进行 for 循环任务调度的管理

nowait

不进行隐式同步

type 的参数有四种:

参数

作用

static

静态调度,任务的分配时固定的

dynamic

动态调度,先完成任务的线程先取下一个任务

guided

先开始的线程获得的任务最多,然后依次递减,最少为 1 or size

runtime

基本不用

omp for 指令中每次循环之间不能有任何互相依赖,否则反而会导致性能下降。例如:

#pragma omp parallel for
for (int i = 0; i < INT_MAX; ++i) {
    result += i;
}

在不使用 omp 时计算时间大概为 2.5s,使用 omp 后时间为 5s+。而且由于 result 是竞态条件,结果还不正确。如果要结果正确,需要对 result 加锁,但是线程间频繁的竞争会导致性能大幅度下降。

而如果这样:

#pragma omp parallel for
for (int i = 0; i < 20; ++i) {
    cout << i << " ";
}

在启用 omp 的情况下耗时为 0.133489s,不启用的时候耗时为 0.11?这里大概是因为缓冲区的问题

现在只进行计算:

int ReduceSum(int end) {
    int res = 0;
    for (int i = 0; i < end; ++i) {
        res += i;
    }
    return res;
}

#pragma omp parallel for
for (int i = 0; i < 20000; ++i) {
    ReduceSum(i);
}

现在不使用 omp 的时候时间为 0.330838,使用 omp 的时候时间为 0.045931。性能提升了 7.2 倍

可见,要使用 omp for 提升性能,需要满足两个条件:

  • for 每次循环之间不能有依赖条件

  • for 语句中尽量不要访问一些加锁区域

动态并行

相同的内核调用语法被用在一个内核内启动一个新的核函数。在动态并行中,内核执行分为两种类型:父母和孩子。父线程、父线程块或父网格启动一个新的网格,即子网格。子线程、子线程块或子网格被父母启动。子网格必须在父线程、父线程块或父网格完成之前完成。只有在所有的子网格都完成之后,父母才会完成。

设备线程中的网格启动,在线程块间是可见的。这意味着,线程可能与由该线程启动的或由相同线程块中其他线程启动的子网格同步

父网格和子网格共享相同的全局和常量内存存储,但它们有不同的局部内存和共享内存

有两个时刻,子网格和它的父线程见到的内存完全相同:子网格开始时和子网格完成时。当父线程优于子网格调用时,所有的全局内存操作要保证对子网格是可见的。当父母在子网格完成时进行同步操作后,子网格所有的内存操作应保证对父母是可见的

共享内存和局部内存分别对于线程块或线程来说是私有的,同时,在父母和孩子之间不是可见或一致的。局部内存对线程来说是私有存储,并且对该线程外部不可见。当启动一个子网格时,向局部内存传递一个指针作为参数是无效的

下面是一个动态并行的 Hello, world 的例子

__global__ void nestedHelloWorld(int iSize, int iDepth) {
   int tid = threadIdx.x;
   printf("Recursion=%d: Hello World from thread %d block %d\n", iDepth, tid, blockIdx.x);
   if (iSize == 1) return;
   int nthreads = iSize >> 1;
   if (tid == 0 && nthreads > 0) {
      nestedHelloWorld <<< 1, nthreads >>> (nthreads, ++iDepth);
      printf("-------> netsted execution depth: %d\n", iDepth);
   }
}
Last moify: 2022-12-04 15:11:33
Build time:2025-07-18 09:41:42
Powered By asphinx