首先,我们会比较CPU和GPU,这能帮助我们更好地了解GPU的发展状况,但这应该作为一个独立的主题,因为我们难以在一节中涵盖其所有的内容。因此,我们将着重介绍一些关键点。
CPU和GPU的主要区别在于它们的设计目标。CPU的设计初衷是执行顺序指令。一直以来,为提高顺序执行性能,CPU设计中引入了许多功能。其重点在于减少指令执行时延,使CPU能够尽可能快地执行一系列指令。这些功能包括指令流水线、乱序执行、预测执行和多级缓存等(此处仅列举部分)。
而GPU则专为大规模并行和高吞吐量而设计,但这种设计导致了中等至高程度的指令时延。这一设计方向受其在视频游戏、图形处理、数值计算以及现如今的深度学习中的广泛应用所影响,所有这些应用都需要以极高的速度执行大量线性代数和数值计算,因此人们倾注了大量精力以提升这些设备的吞吐量。
我们来思考一个具体的例子:由于指令时延较低,CPU在执行两个数字相加的操作时比GPU更快。在按顺序执行多个这样的计算时,CPU能够比GPU更快地完成。然而,当需要进行数百万甚至数十亿次这样的计算时,由于GPU具有强大的大规模并行能力,它将比CPU更快地完成这些计算任务。
我们可以通过具体数据来进行说明。硬件在数值计算方面的性能以每秒浮点运算次数(FLOPS)来衡量。NVIDIA的Ampere A100在32位精度下的吞吐量为19.5 TFLOPS。相比之下,Intel的24核处理器在32位精度下的吞吐量仅为0.66 TFLOPS(2021年)。同时,随时间推移,GPU与CPU在吞吐量性能上的差距逐年扩大。
CPU在芯片领域中主要用于降低指令时延的功能,例如大型缓存、较少的算术逻辑单元(ALU)和更多的控制单元。与此相比,GPU则利用大量的ALU来最大化计算能力和吞吐量,只使用极小的芯片面积用于缓存和控制单元,这些元件主要用于减少CPU时延。
时延容忍度和高吞吐量
或许你会好奇,GPU如何能够容忍高时延并同时提供高性能呢?GPU 拥有大量线程和强大的计算能力,使这一点成为可能。即使单个指令具有高延迟,GPU 也会有效地调度线程运行,以便它们在任意时间点都能利用计算能力。例如,当某些线程正在等待指令结果时,GPU 将切换到运行其他非等待线程。这可确保 GPU 上的计算单元在所有时间点都以其最大容量运行,从而提供高吞吐量。稍后当我们讨论kernel如何在 GPU 上运行时,我们将对此有更清晰的了解。
我们已经了解到GPU有利于实现高吞吐量,但它们是通过怎样的架构来实现这一目标的呢?本节将对此展开探讨。
GPU的计算架构
GPU由一系列流式多处理器(SM)组成,其中每个SM又由多个流式处理器、核心或线程组成。例如,NVIDIA H100 GPU具有132个SM,每个SM拥有64个核心,总计核心高达8448个。
每个SM都拥有一定数量的片上内存(on-chip memory),通常称为共享内存或临时存储器,这些共享内存被所有的核心所共享。同样,SM上的控制单元资源也被所有的核心所共享。此外,每个SM都配备了基于硬件的线程调度器,用于执行线程。
除此之外,每个SM还配备了几个功能单元或其他加速计算单元,例如张量核心(tensor core)或光线追踪单元(ray tracing unit),用于满足GPU所处理的工作负载的特定计算需求。
接下来,让我们深入剖析GPU内存并了解其中的细节。
GPU的内存架构
让我们对其进行剖析:
现在我们已经了解GPU硬件的关键组成部分,接下来我们深入一步,了解执行代码时这些组件是如何发挥作用的。
要理解GPU如何执行kernel,我们首先需要了解什么是kernel及其配置。
CUDA Kernel与线程块简介
CUDA是NVIDIA提供的编程接口,用于编写运行在其GPU上的程序。在CUDA中,你会以类似于C/C++函数的形式来表达想要在GPU上运行的计算,这个函数被称为kernel。kernel在并行中操作向量形式的数字,这些数字以函数参数的形式提供给它。一个简单的例子是执行向量加法的kernel,即接受两个向量作为输入,逐元素相加,并将结果写入第三个向量。
要在GPU上执行kernel,我们需要启用多个线程,这些线程总体上被称为一个网格(grid),但网格还具有更多的结构。一个网格由一个或多个线程块(有时简称为块)组成,而每个线程块又由一个或多个线程组成。
线程块和线程的数量取决于数据的大小和我们所需的并行度。例如,在向量相加的示例中,如果我们要对256维的向量进行相加运算,那么可以配置一个包含256个线程的单个线程块,这样每个线程就可以处理向量的一个元素。如果数据更大,GPU上也许没有足够的线程可用,这时我们可能需要每个线程能够处理多个数据点。
编写一个kernel需要两步。第一步是运行在CPU上的主机代码,这部分代码用于加载数据,为GPU分配内存,并使用配置的线程网格启动kernel;第二步是编写在GPU上执行的设备(GPU)代码。
由于本文的重点不在于教授CUDA,因此我们不会更深入地讨论此段代码。现在,让我们看看在GPU上执行kernel的具体步骤。
1.将数据从主机复制到设备
在调度执行kernel之前,必须将其所需的全部数据从主机(即CPU)内存复制到GPU的全局内存(即设备内存)。尽管如此,在最新的GPU硬件中,我们还可以使用统一虚拟内存直接从主机内存中读取数据(可参阅论文《EMOGI: Efficient Memory-access for Out-of-memory Graph-traversal in GPUs》)。
2. SM上线程块的调度
当GPU的内存中拥有全部所需的数据后,它会将线程块分配给SM。同一个块内的所有线程将同时由同一个SM进行处理。为此,GPU必须在开始执行线程之前在SM上为这些线程预留资源。在实际操作中,可以将多个线程块分配给同一个SM以实现并行执行。
由于SM的数量有限,而大型kernel可能包含大量线程块,因此并非所有线程块都可以立即分配执行。GPU会维护一个待分配和执行的线程块列表,当有任何一个线程块执行完成时,GPU会从该列表中选择一个线程块执行。
3. 单指令多线程 (SIMT) 和线程束(Warp)
众所周知,一个块(block)中的所有线程都会被分配到同一个SM上。但在此之后,线程还会进一步划分为大小为32的组(称为warp),并一起分配到一个称为处理块(processing block)的核心集合上进行执行。
SM通过获取并向所有线程发出相同的指令,以同时执行warp中的所有线程。然后这些线程将在数据的不同部分,同时执行该指令。在向量相加的示例中,一个warp中的所有线程可能都在执行相加指令,但它们会在向量的不同索引上进行操作。
由于多个线程同时执行相同的指令,这种warp的执行模型也称为单指令多线程 (SIMT)。这类似于CPU中的单指令多数据(SIMD)指令。
Volta及其之后的新一代GPU引入了一种替代指令调度的机制,称为独立线程调度(Independent Thread Scheduling)。它允许线程之间完全并发,不受warp的限制。独立线程调度可以更好地利用执行资源,也可以作为线程之间的同步机制。本文不会涉及独立线程调度的相关内容,但你可以在CUDA编程指南中了解更多相关信息。
4. Warp调度和时延容忍度
关于warp的运行原理,有一些值得讨论的有趣之处。
即使SM内的所有处理块(核心组)都在处理warp,但在任何给定时刻,只有其中少数块正在积极执行指令。因为SM中可用的执行单元数量是有限的。
有些指令的执行时间较长,这会导致warp需要等待指令结果。在这种情况下,SM会将处于等待状态的warp休眠,并执行另一个不需要等待任何结果的warp。这使得GPU能够最大限度地利用所有可用计算资源,并提高吞吐量。
零计算开销调度:由于每个warp中的每个线程都有自己的一组寄存器,因此SM从执行一个warp切换到另一个warp时没有额外计算开销。
与CPU上进程之间的上下文切换方式(context-switching)不同。如果一个进程需要等待一个长时间运行的操作,CPU在此期间会在该核心上调度执行另一个进程。然而,在CPU中进行上下文切换的代价昂贵,这是因为CPU需要将寄存器状态保存到主内存中,并恢复另一个进程的状态。
5. 将结果数据从设备复制到主机内存
最后,当kernel的所有线程都执行完毕后,最后一步就是将结果复制回主机内存。
尽管我们涵盖了有关典型kernel执行的全部内容,但还有一点值得讨论:动态资源分区。
我们通过一个称为“occupancy(占用率)”的指标来衡量GPU资源的利用率,它表示分配给SM的warp数量与SM所能支持的最大warp数量之间的比值。为实现最大吞吐量,我们希望拥有100%的占用率。然而,在实践中,由于各种约束条件,这并不容易实现。
为什么我们无法始终达到100%的占用率呢?SM拥有一组固定的执行资源,包括寄存器、共享内存、线程块槽和线程槽。这些资源根据需求和GPU的限制在线程之间进行动态划分。例如,在NVIDIA H100上,每个SM可以处理32个线程块、64个warp(即2048个线程),每个线程块拥有1024个线程。如果我们启动一个包含1024个线程的网格,GPU将把2048个可用线程槽划分为2个线程块。
动态分区vs固定分区:动态分区能够更为有效地利用GPU的计算资源。相比之下,固定分区为每个线程块分配了固定数量的执行资源,这种方式并不总是最有效的。在某些情况下,固定分区可能会导致线程被分配多于其实际需求的资源,造成资源浪费和吞吐量降低。
下面我们通过一个例子说明资源分配对SM占用率的影响。假设我们使用32个线程的线程块,并需要总共2048个线程,那么我们将需要64个这样的线程块。然而,每个SM一次只能处理32个线程块。因此,即使一个SM可以运行2048个线程,但它一次也只能同时运行1024个线程,占用率仅为50%。
同样地,每个SM具有65536个寄存器。要同时执行2048个线程,每个线程最多有32个寄存器(65536/2048 =32)。如果一个kernel需要每个线程有64个寄存器,那么每个SM只能运行1024个线程,占用率同样为50%。
占用率不足的挑战在于,可能无法提供足够的时延容忍度或所需的计算吞吐量,以达到硬件的最佳性能。
高效创建GPU kernel是一项复杂任务。我们必须合理分配资源,在保持高占用率的同时尽量降低时延。例如,拥有大量寄存器可以加快代码的运行速度,但可能会降低占用率,因此谨慎优化代码至关重要。
我理解众多的新术语和新概念可能令读者望而生畏,因此文章最后对要点进行了总结,以便快速回顾。