如何设计GPU芯片
Chapter 3 Parallel Programming Models

第 3 章:GPU 设计中的并行编程模型

图形处理单元 (GPU) 已从固定功能的图形加速器演变为高度并行的可编程计算引擎,可以加速各种应用程序。为了使程序员能够有效利用 GPU 中的大规模并行性,已经开发了几种并行编程模型和 API,例如 NVIDIA CUDA、OpenCL 和 DirectCompute。这些编程模型提供了抽象,允许程序员在隐藏 GPU 硬件低级细节的情况下表达应用程序中的并行性。

在这一章中,我们将探讨针对 GPU 的并行编程模型的关键概念和原理,重点关注 SIMT (单指令,多线程) 执行模型、CUDA 编程模型和 API,以及 OpenCL 框架。我们还将讨论将算法映射到 GPU 架构以实现高性能和效率的技术。

SIMT (单指令,多线程) 执行模型

SIMT 执行模型是现代 GPU 用于实现大规模并行性的基本范式。在 SIMT 模型中,大量线程并行执行相同的程序(称为内核),但每个线程都有自己的程序计数器,并可以根据其线程 ID 和所操作的数据采取不同的执行路径。

内核和线程层次结构

GPU 内核是一个由大量线程并行执行的函数。在启动内核时,程序员指定要创建的线程数量以及它们如何组织成网格、块(或合作线程数组 - CTA)和单个线程的层次结构。

  • 网格代表整个问题空间,由一个或多个块组成。
  • 块是一组可以相互合作和同步的线程,通过共享内存和屏障实现。块内的线程在同一个 GPU 内核(称为流式多处理器)上执行。以下是该 Markdown 文件的中文翻译。对于代码部分,我只翻译了注释,而没有翻译代码本身。

(或计算单元)。

  • 每个线程在其块和网格中都有一个唯一的 ID,可用于计算内存地址和做出控制流决策。

这种分层组织允许程序员表达数据并行性(同一操作应用于多个数据元素)和任务并行性(不同任务并行执行)。

图 3.1 说明了 SIMT 执行模型中的线程层次结构。

            网格
       ________________
      /   /   /   /   /
     /   /   /   /   / 
    /   /   /   /   /
   /   /   /   /   /
  /__/__/__/__/__/
    |   |   |   |
    |   |  块  |
    |   |   |   |
  线程 线程 ...

图 3.1: SIMT 执行模型中的线程层次结构。

SIMT 执行

在 SIMT 执行模型中,每个线程执行相同的指令,但操作不同的数据。然而,与 SIMD(单指令多数据)不同,SIMT 允许线程有独立的执行路径,并在分支指令处发散。

当一个 warp(NVIDIA GPU 中的 32 个线程组或 AMD GPU 中的 64 个线程组)遇到分支指令时,GPU 硬件会评估 warp 中每个线程的分支条件。如果所有线程采取相同的路径(收敛),warp 将继续正常执行。但是,如果一些线程采取不同的路径(发散),warp 将被拆分成两个或更多个子 warp,每个子 warp 都遵循不同的路径。GPU 硬件会串行执行分歧路径,屏蔽每个子 warp 中的非活动线程。当所有路径完成时,子 warp 会重新汇聚并继续同步执行。

图 3.2 说明了具有分散控制流的 SIMT 执行。

         Warp
   ________________
  /   /   /   /   /
 /   /   /   /   / 
/   /   /   /   /
   |   |   |
   | 分支 |
   |   |   |
  / \ / \ / \
 /   X   \   \
/   / \   \   \
   /   \   \
  /     \   \
 /       \   \
/         \   \
           \
```重新集中

图3.2:SIMT执行具有分散控制流的情况。

这种分散处理机制允许SIMT支持比SIMD更灵活的控制流,但当出现分散时会降低SIMD效率。程序员应该努力最小化一个线程束内的分散,以达到最佳性能。

内存层次结构

GPU拥有复杂的内存层次结构,以支持并行工作负载的高带宽和低延迟要求。内存层次结构通常由以下部分组成:

  • 全局内存:最大但最慢的内存空间,所有内核中的线程都可以访问。全局内存通常使用高带宽的GDDR或HBM内存实现。
  • 共享内存:一个快速的片上内存空间,由一个块中的所有线程共享。共享内存用于线程间通信和块内数据共享。
  • 常量内存:一个只读内存空间,用于将只读数据广播到所有线程。
  • 纹理内存:一个优化用于空间局部性并通过纹理缓存访问的只读内存空间。纹理内存更常用于图形工作负载。
  • 局部内存:每个线程的私有内存空间,用于寄存器溢出和大数据结构。局部内存通常映射到全局内存。

有效利用内存层次结构对于在GPU上实现高性能至关重要。程序员应该努力最大化共享内存的使用,并最小化对全局内存的访问,以减少内存延迟和带宽瓶颈。

图3.3展示了GPU的内存层次结构。

|---|---|
| Shared Memory | 共享内存 |
| Local Memory | 局部内存 |
| Figure 3.3: GPU memory hierarchy. | 图 3.3: GPU 内存层次结构。 |
| CUDA Programming Model and APIs | CUDA 编程模型和 API |
| CUDA (Compute Unified Device Architecture) is a parallel computing platform and programming model developed by NVIDIA for general-purpose computing on GPUs. CUDA provides a set of extensions to standard programming languages, such as C, C++, and Fortran, that enable programmers to express parallelism and leverage the computational power of NVIDIA GPUs. | CUDA (Compute Unified Device Architecture) 是 NVIDIA 为通用 GPU 计算开发的并行计算平台和编程模型。CUDA 为标准编程语言(如 C、C++ 和 Fortran)提供了一系列扩展,使程序员能够表达并行性并利用 NVIDIA GPU 的计算能力。 |
| CUDA Programming Model | CUDA 编程模型 |
| The CUDA programming model is based on the concept of kernels, which are functions executed in parallel by a large number of threads on the GPU. The programmer specifies the number of threads to be launched and their organization into a grid of thread blocks. | CUDA 编程模型基于内核的概念,内核是在 GPU 上并行执行的大量线程函数。程序员指定要启动的线程数量及其在线程块网格中的组织方式。 |
| Thread: The basic unit of execution in CUDA. Each thread has its own program counter, registers, and local memory. | 线程:CUDA 中执行的基本单元。每个线程都有自己的程序计数器、寄存器和局部内存。 |
| Block: A group of threads that can cooperate and synchronize with each other. Threads within a block are executed on the same streaming multiprocessor and can communicate via shared memory. | 块:一组可以相互协作和同步的线程。块内的线程在同一个流多处理器上执行,可以通过共享内存进行通信。 |
| Grid: A collection of thread blocks that execute the same kernel. The grid represents the entire problem space and can be one-, two-, or three-dimensional. | 网格:执行同一个内核的一组线程块。网格代表整个问题空间,可以是一维、二维或三维的。 |
| CUDA also provides built-in variables (e.g., threadIdx, blockIdx, blockDim, gridDim) that allow threads to identify themselves and compute memory addresses based on their position in the thread hierarchy. | CUDA 还提供了内置变量(如 threadIdx、blockIdx、blockDim 和 gridDim),允许线程根据在线程层次结构中的位置来识别自己并计算内存地址。 |
| Figure 3.4: CUDA programming model. | 图 3.4: CUDA 编程模型。 |下面是这个 Markdown 文件的中文翻译版本,其中代码部分的注释进行了翻译,代码本身没有翻译。

CUDA 将 GPU 内存层次结构暴露给程序员,允许对数据放置和移动进行显式控制。CUDA 中的主要内存空间有:

- 全局内存:可被内核中的所有线程访问,并在内核启动之间持续存在。全局内存延迟较高,通常用于大型数据结构。
- 共享内存:一种快速的片上内存,由同一个块中的所有线程共享。共享内存用于线程间通信和数据共享。
- 常量内存:一种只读内存空间,用于将只读数据广播到所有线程。常量内存已缓存,提供低延迟访问。
- 纹理内存:一种针对空间局部性进行优化的只读内存空间,通过纹理缓存访问。纹理内存更常用于图形工作负载。
- 局部内存:每个线程的私有内存空间,用于寄存器溢出和大型数据结构。局部内存通常映射到全局内存。

程序员可以使用 CUDA 运行时 API(如 cudaMalloc、cudaMemcpy 和 cudaFree)在主机(CPU)和设备(GPU)内存之间分配和传输数据。

图 3.5 说明了 CUDA 的内存层次结构。

| | | Global | | Memory |


|


| | | Constant | | Memory |


|


| | | Texture | | Memory |


| |


| | | Shared | | Memory |


|


| | | Local | | Memory |


图 3.5:CUDA 内存层次结构。

### CUDA 同步和协调

CUDA 提供了同步和协调原语,以使线程之间能够进行合作和通信:

- 屏障同步:__syncthreads()以下是文件的中文翻译:

s()函数充当一个屏障,确保块中的所有线程在继续之前都到达了同一个点。

- 原子操作:CUDA支持原子操作(例如,atomicAdd,atomicExch),允许线程在没有其他线程干扰的情况下对共享或全局内存执行读-改-写操作。
- 线程束级原语:CUDA提供了线程束级内置函数(例如,__shfl,__ballot),可以在线程束内进行高效的通信和同步。

正确使用同步和协调原语对于在CUDA中编写正确和高效的并行程序至关重要。

示例3.1显示了一个简单的CUDA内核,它执行向量加法。

```c
// 执行向量加法的CUDA内核
__global__ void vectorAdd(int *a, int *b, int *c, int n) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) {
        c[i] = a[i] + b[i];
    }
}

int main() {
    int *a, *b, *c;
    int n = 1024;
    
    // 在主机上分配内存
    a = (int*)malloc(n * sizeof(int));
    b = (int*)malloc(n * sizeof(int));
    c = (int*)malloc(n * sizeof(int));
    
    // 初始化输入向量
    for (int i = 0; i < n; i++) {
        a[i] = i;
        b[i] = i * 2;
    }
    
    // 在设备上分配内存
    int *d_a, *d_b, *d_c;
    cudaMalloc(&d_a, n * sizeof(int));
    cudaMalloc(&d_b, n * sizeof(int));
    cudaMalloc(&d_c, n * sizeof(int));
    
    // 将输入向量从主机复制到设备
    cudaMemcpy(d_a, a, n * sizeof(int), cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, b, n * sizeof(int), cudaMemcpyHostToDevice);
    
    // 启动内核
    int blockSize = 256;
    int numBlocks = (n + blockSize - 1) / blockSize;
    vectorAdd<<<numBlocks,blockSize>>>(d_a, d_b, d_c, n);
    
    // 将结果向量从设备复制到主机
    cudaMemcpy(c, d_c, n * sizeof(int), cudaMemcpyDeviceToHost);
    
    // 释放设备内存
    cudaFree(d_a);
    cudaFree(d_b); 
    cudaFree(d_c);
    
    // 释放主机内存
    free(a); 
    free(b);
    free(c);
    
    return 0;
}
```以下是提供的 Markdown 文件的中文翻译。代码部分中,注释被翻译,但代码本身没有被翻译。

```c
int n = 0;
}

这个 CUDA 代码启动了 vectorAdd 内核,使用 numBlocks 个块和每个块 blockSize 个线程。该内核执行输入向量 ab 的元素级加法,并将结果存储在向量 c 中。<<<...>>> 语法用于在启动内核时指定网格和块维度。

CUDA 流和事件

CUDA 流和事件提供了一种用于内核和内存操作的并发执行和同步的机制:

  • 流: 按顺序执行的一系列操作(内核启动、内存拷贝)。不同的流可以并发执行,允许计算和内存传输重叠。
  • 事件: 可以插入到流中的标记,用于记录特定操作的完成。事件可用于同步和计时目的。

流和事件使程序员能够通过重叠计算和内存传输,以及利用GPU硬件的全部功能,来优化 CUDA 应用程序的性能。

示例 3.2 演示了使用 CUDA 流来重叠内核执行和内存传输。

// 创建两个流
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
 
// 异步将输入数据拷贝到设备
cudaMemcpyAsync(d_a, a, size, cudaMemcpyHostToDevice, stream1);
cudaMemcpyAsync(d_b, b, size, cudaMemcpyHostToDevice, stream2);
 
// 在不同的流中启动内核
kernelA<<<blocks, threads, 0, stream1>>>(d_a);
kernelB<<<blocks, threads, 0, stream2>>>(d_b);
 
// 异步将结果拷贝回主机
cudaMemcpyAsync(a, d_a, size, cudaMemcpyDeviceToHost, stream1);
cudaMemcpyAsync(b, d_b, size, cudaMemcpyDeviceToHost, stream2);
 
// 同步流
cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);

在这个示例中,创建了两个 CUDA 流。使用每个流异步拷贝输入数据到设备。然后在不同的流中启动内核,并将结果异步拷贝回主机。最后,同步两个流。以下是该 Markdown 文件的中文翻译版本。对于代码部分,只翻译注释,代码本身不进行翻译。

OpenCL 框架

OpenCL (Open Computing Language) 是一个开放、免版税的并行编程标准,适用于包括 CPU、GPU、FPGA 和其他加速器在内的异构平台。OpenCL 提供了统一的编程模型和一组 API,使开发人员能够编写可移植和高效的并行代码。

OpenCL 编程模型

OpenCL 编程模型与 CUDA 类似,但在术语和抽象概念上有一些关键差异:

  • Kernel: 在 OpenCL 设备上并行执行的函数,由大量 work-items (线程) 执行。
  • Work-item: OpenCL 中基本的执行单元,相当于 CUDA 中的线程。
  • Work-group: 一组可以同步和通过局部内存共享数据的 work-items。Work-group 相当于 CUDA 中的线程块。
  • NDRange: 定义 kernel 执行时的索引空间和 work-item 组织方式,可以是一维、二维或三维。

OpenCL 还定义了与 CUDA 类似的分层内存模型:

  • 全局内存: 可被所有 work-items 和所有 work-group 访问,相当于 CUDA 中的全局内存。
  • 局部内存: 可被同一个 work-group 中的所有 work-items 共享,相当于 CUDA 中的共享内存。
  • 私有内存: 属于单个 work-item 的私有内存,相当于 CUDA 中的寄存器。
  • 常量内存: 只读内存,可被所有 work-items 访问。

OpenCL kernel 在运行时由 OpenCL 运行时编译。主机程序可以查询可用的 OpenCL 设备,选择合适的设备,创建上下文,并为该特定设备构建 kernel。这使得 OpenCL 应用程序可以在不同的硬件平台上实现高度的可移植性。

示例 3.3 展示了一个执行向量加法的 OpenCL kernel,与示例 3.1 中的 CUDA 示例类似。

__kernel void vector_add(const __global int *a, const __global int *b, __global int *c, int n) {
    int i = get_global_id(0);
    if (i < n) {
        c[i] = a[i] + b[i];
    }
}

以下是对该文件的中文翻译:

__kernel关键字定义了一个OpenCL内核函数。__global关键字指定了一个指针指向全局内存。get_global_id函数返回当前工作项的全局索引,这用于计算输入和输出向量的内存地址。

将算法映射到GPU架构

高效地将算法映射到GPU架构对于实现高性能至关重要。关键考虑因素包括:

  • 暴露足够的并行性:算法应该被分解成许多细粒度的线程,这些线程可以并发执行,以充分利用GPU的并行处理能力。

  • 最小化分支发散:在一个warp/wavefront中的发散控制流可能导致串行化和降低SIMD效率。算法应该尽量减少分支发散。

  • 利用内存层次结构:访问全局内存很昂贵。算法应该最大化使用共享内存和寄存器,以减少全局内存访问。数据还应该以一种能够实现并集内存访问的方式布置在内存中。

  • 平衡计算和内存访问:算法应该有较高的算术运算与内存操作比例,以有效地隐藏内存延迟,并实现高计算吞吐量。

  • 最小化主机-设备数据传输:在主机和设备内存之间传输数据很慢。算法应尽可能在GPU上执行更多计算,以降低此类传输。

在开发GPU内核时,通常使用以下几种并行算法设计模式:

  • 映射(Map):每个线程对不同的数据元素执行相同的操作,实现大型数据集的简单并行处理。

  • 规约(Reduce):并行规约用于有效地从大型输入数据集计算出单个值(如求和、求最大值等)。线程执行局部归约,然后将其组合以产生最终结果。

  • 扫描(Scan):也称为前缀和,用于计算数组中元素的累加和。高效的并行扫描算法是许多 GPU 加速应用程序的关键构建块。

  • 模板(Stencil):每个线程根据相邻的数据元素计算一个值。模板计算在科学模拟和图像处理应用程序中很常见。

  • 聚合/散射(Gather/Scatter):线程从全局内存中的任意位置读取(聚合)或写入(散射)数据。需要仔细的数据布局和访问模式才能实现高效。

结论

CUDA 和 OpenCL 等 GPU 编程模型将现代 GPU 的并行处理能力暴露给开发人员,使他们能够加速各种应用程序。这些编程模型提供了抽象,允许将细粒度的并行工作负载有效地映射到 GPU 硬件上。

理解这些编程模型提供的执行模型、内存层次结构和同步原语是编写高性能 GPU 代码的关键。开发人员必须仔细考虑线程组织、分支散发、内存访问模式和算法设计等因素,才能充分利用 GPU 的计算能力。

随着 GPU 架构的不断发展,编程模型和工具也必须不断发展,以使开发人员能够有效利用新的硬件功能和功能。在编程语言设计、编译器优化和自动调优等领域的持续研究将对提高程序员生产力和性能便携性至关重要。