CUDA C++ 编程指北-第三章 GPU硬件实现

NVIDIA GPU架构是围绕可扩展的多线程流式多处理器SMs)阵列构建的。当主机CPU上的CUDA程序调用一个内核网格时,网格的块会被枚举并分配给具有可用执行能力的多处理器。一个线程块的线程在一个多处理器上并发执行,一个多处理器上可以并发执行多个线程块。当线程块终止时,新的块在空闲的多处理器上启动。

一个多处理器被设计为并发执行数百个线程。为了管理如此大量的线程,它采用了一种称为SIMT单指令,多线程)的独特架构,该架构在SIMT架构中有描述。指令是流水线式的,利用单个线程内的指令级并行性,以及通过同时的硬件多线程实现广泛的线程级并行性,如硬件多线程中详细描述的那样。与CPU核心不同,它们是按顺序发出的,没有分支预测或推测执行。

SIMT架构硬件多线程描述了流处理器的架构特性,这些特性对所有设备都是通用的。计算能力5.x计算能力6.x计算能力7.x分别为计算能力5.x、6.x和7.x的设备提供了具体细节。

NVIDIA GPU架构使用小端表示法。

SIMT Architecture

多处理器创建、管理、调度并执行称为warps的32个并行线程组。组成warp的单个线程在同一程序地址上同时开始,但它们有自己的指令地址计数器和寄存器状态,因此可以自由地分支和独立执行。warp一词来源于编织(weaving),编织(weaving)是一种古老的技术,其中多个线程交织在一起创建一个布料。在这里,这个词被用作一个比喻,表示多个线程并行工作,就像在编织中的线程一样。半warp是warp的第一半或第二半。四分之一warp是warp的第一、第二、第三或第四部分( A half-warp is either the first or second half of a warp. A quarter-warp is either the first, second, third, or fourth quarter of a warp)。

详细解释下上述这段内容:

  • 在CUDA架构中,多处理器是执行线程的硬件单元。这里提到的“warp”是一个包含32个线程的组,这些线程在多处理器上并行执行。
  • 尽管所有线程都从同一个程序地址开始,但每个线程都有自己的指令计数器和寄存器状态。这意味着,尽管它们开始时执行相同的指令,但随后可以根据各自的数据和条件独立地分支和执行。
  • 一个warp包含32个线程,所以一个“半warp”就是其中的16个线程,可以是前16个或后16个线程。一个warp被分为四个部分,每部分包含8个线程。所以,“四分之一warp”可以是warp的前8个、第9到16个、第17到24个或最后8个线程。

当一个多处理器被赋予一个或多个要执行的线程块时,它将它们划分为warps,每个warp由warp调度器调度执行。一个块被划分为warps的方式总是相同的;每个warp包含连续的、递增的线程ID,第一个warp包含线程0。线程层次结构描述了线程ID如何与块中的线程索引相关。

一个warp一次执行一个公共指令,所以当一个warp的所有32个线程执行路径都一致(没有分支),就会实现完全效率。如果warp的线程通过数据依赖的条件分支发散,warp会执行每个分支路径,禁用不在该路径上的线程。分支发散只在warp内部发生;不同的warps无论它们是否执行公共或不同的代码路径都是独立执行的。

SIMT架构类似于SIMD(单指令,多数据)向量组织,其中一个单独的指令控制多个处理元素。一个关键的区别是SIMD向量组织将SIMD宽度暴露给软件,而SIMT指令指定单个线程的执行和分支行为。与SIMD向量机器相比,SIMT允许程序员为独立的、标量的线程编写线程级并行代码,以及为协调的线程编写数据并行代码。为了正确性的目的,程序员基本上可以忽略SIMT行为;然而,通过确保代码很少需要warp中的线程发散,可以实现大量的性能提升(however, substantial performance improvements can be realized by taking care that the code seldom requires threads in a warp to diverge)。在实践中,这类似于传统代码中缓存行的作用:在设计正确性时可以安全地忽略缓存行大小,但在设计峰值性能时必须考虑代码结构。另一方面,向量架构要求软件将加载合并为向量,并手动管理发散。

详细解释下上述这段:

  • 为了确保程序的正确性,程序员不必深入了解SIMT(单指令多线程)的行为。也就是说,即使程序员不完全理解SIMT的工作原理,他们编写的代码仍然可以正确运行
  • 在GPU中,一个warp是一组线程,这些线程同时执行相同的指令。但是,如果某些线程需要执行不同的指令(例如,由于条件分支),那么这些线程就会“发散”。当warp中的线程发散时,它们不能同时执行,这会降低性能。因此,为了实现最佳性能,程序员应尽量避免这种发散
  • 传统的CPU编程中,缓存行是内存的一个块,它在物理上连续,并且作为一个整体被加载到缓存中。程序员在编写代码时,为了确保程序的正确性,不需要考虑缓存行的大小。但是,为了获得最佳性能,他们需要考虑如何组织数据,以便有效地利用缓存行,减少缓存未命中
  • 向量架构,如SIMD(单指令多数据)架构,要求程序员明确地管理数据的并行性。在这种架构中,数据被组织成向量,并且一次操作可以同时应用于整个向量。为了获得最佳性能,程序员需要确保数据加载是“合并”的,这意味着数据应该在内存中连续存放。此外,程序员还需要手动管理线程或数据元素之间的发散

在NVIDIA Volta之前,warps使用一个由warp中的所有32个线程共享的单一程序计数器,以及一个指定warp的活动线程的活动掩码。因此,来自同一warp的处于发散区域或不同执行状态的线程不能相互发送信号或交换数据,而且需要锁或互斥体保护的数据的细粒度共享的算法很容易导致死锁,这取决于争用线程来自哪个warp。

从NVIDIA Volta架构开始,独立线程调度允许线程之间完全并发,无论warp如何。通过独立线程调度,GPU为每个线程维护执行状态,包括程序计数器和调用堆栈,并可以以每个线程的粒度产生执行,要么更好地利用执行资源,要么允许一个线程等待另一个线程产生的数据。一个调度优化器确定如何将来自同一warp的活动线程组合成SIMT单元。这保留了与之前的NVIDIA GPU中的SIMT执行相同的高吞吐量,但具有更大的灵活性:线程现在可以在子warp粒度上发散和重新汇合。

独立线程调度可能导致参与执行代码的线程集合与开发人员对先前硬件架构的warp同步性做出的假设大不相同。特别是,任何warp同步的代码(例如无同步、warp内规约)都应重新检查,以确保与NVIDIA Volta及更高版本的兼容性。有关更多详细信息,请参阅Compute Capability 7.x

参与当前指令的warp的线程被称为活动线程,而不在当前指令上的线程是非活动的(已禁用)。线程可能因各种原因处于非活动状态,包括比其warp的其他线程更早地退出,采取与warp当前执行的分支路径不同的分支路径,或者是线程数不是warp大小的倍数的块的最后线程。
如果由warp执行的非原子指令为warp的多个线程的相同位置在全局或共享内存中写入,那么发生在该位置的序列化写入的数量取决于设备的计算能力(参见计算能力5.x、计算能力6.x和计算能力7.x),以及执行最终写入的线程是未定义的。
如果由warp执行的原子指令为warp的多个线程在全局内存中的相同位置读取、修改和写入,那么对该位置的每个读/修改/写都会发生,它们都会被序列化,但它们发生的顺序是未定义的。

Hardware Multithreading

由多处理器处理的每个warp的执行上下文(程序计数器、寄存器等)在warp的整个生命周期中都在芯片上维护。因此,从一个执行上下文切换到另一个执行上下文没有成本,每次指令发出时,warp调度器选择一个有线程准备执行其下一指令的warp(warp的活动线程)并向这些线程发出指令。

特别地,每个多处理器都有一组32位寄存器,这些寄存器在warp之间进行划分,以及一个并行数据缓存共享内存,该内存在线程块之间进行划分。

对于给定的内核,可以在多处理器上驻留和一起处理的块和warp的数量取决于内核使用的寄存器和共享内存的数量以及多处理器上可用的寄存器和共享内存的数量。每个多处理器还有一个最大的驻留块数和一个最大的驻留warp数。这些限制以及多处理器上可用的寄存器和共享内存的数量是设备的计算能力的函数,并在计算能力中给出。如果每个多处理器没有足够的寄存器或共享内存来处理至少一个块,内核将无法启动。

一个块中的warp总数如下:

\text{ceil}\left( \frac{T}{W_{size}},1 \right)

  • T 是每个块的线程数,
  • Wsize 是warp大小,等于32,
  • ceil(x, y) 等于x四舍五入到最接近的y的倍数。

为块分配的寄存器总数和共享内存总量在CUDA工具包中提供的CUDA占用率计算器中有记录。

术语warp-synchronous指的是隐式地假设在同一个warp中的线程在每个指令上都是同步的代码。

参考

参考