大多数工程师对C++PU和顺序编程都十分熟悉,这是因为自从他们开始编写CPU代码以来,就与之密切接触。然而,对于GPU的内部工作原理及其独特之处,他们的了解则相对较少。过去十年,由于GPU在深度学习中得到广泛应用而变得极为重要。因此,每位软件工程师都有必要了解其基本工作原理。本文旨在为读者提供这方面的背景知识。
本文作者为软件工程师Abhinav Upadhyay,他在《大规模并行处理器编程》第四版(Hwu等)的基础上编写了本文大部分内容,其中介绍了包括GPU体系结构和执行模型等内容。当然,文中GPU编程的基本概念和方法同样适用于其他供应商的产品。
(本文由OneFlow编译发布,转载请联系授权。原文:https://codeconfessions.substack.com/p/gpu-computing)
作者 | Abhinav Upadhyay
OneFlow编译
翻译|宛子琳、杨婷
1
比较CPU与GPU
首先,我们会比较CPU和GPU,这能帮助我们更好地了解GPU的发展状况,但这应该作为一个独立的主题,因为我们难以在一节中涵盖其所有的内容。因此,我们将着重介绍一些关键点。
CPU和GPU的主要区别在于它们的设计目标。CPU的设计初衷是执行顺序指令[1]。一直以来,为提高顺序执行性能,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和GPU的架构进行了比较。
图1:CPU与GPU的芯片设计对比。引自《CUDA C++编程指南》(NVIDIA)
如图所示,CPU在芯片领域中主要用于降低指令时延的功能,例如大型缓存、较少的算术逻辑单元(ALU)和更多的控制单元。与此相比,GPU则利用大量的ALU来最大化计算能力和吞吐量,只使用极小的芯片面积用于缓存和控制单元,这些元件主要用于减少CPU时延。
时延容忍度和高吞吐量
或许你会好奇,GPU如何能够容忍高时延并同时提供高性能呢?GPU 拥有大量线程和强大的计算能力,使这一点成为可能。即使单个指令具有高延迟,GPU 也会有效地调度线程运行,以便它们在任意时间点都能利用计算能力。例如,当某些线程正在等待指令结果时,GPU 将切换到运行其他非等待线程。这可确保 GPU 上的计算单元在所有时间点都以其最大容量运行,从而提供高吞吐量。稍后当我们讨论kernel如何在 GPU 上运行时,我们将对此有更清晰的了解。
2
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所处理的工作负载的特定计算需求。
图2:GPU的计算架构
接下来,让我们深入剖析GPU内存并了解其中的细节。
GPU的内存架构
GPU具有多层不同类型的内存,每一层都有其特定用途。下图显示了GPU中一个SM的内存层次结构。
图3:基于康奈尔大学虚拟工作坊(Virtual Workshop)的GPU内存架构
让我们对其进行剖析:
-
寄存器:让我们从寄存器开始。GPU中的每个SM都拥有大量寄存器。例如,NVIDIA的A100和H100模型中,每个SM拥有65536个寄存器。这些寄存器在核心之间共享,并根据线程需求动态分配。在执行过程中,每个线程都被分配了私有寄存器,其他线程无法读取或写入这些寄存器。
-
常量缓存:接下来是芯片上的常量缓存。这些缓存用于缓存SM上执行的代码中使用的常量数据。为利用这些缓存,程序员需要在代码中明确将对象声明为常量,以便GPU可以将其缓存并保存在常量缓存中。
-
共享内存:每个SM还拥有一块共享内存或临时内存,它是一种小型、快速且低时延的片上可编程SRAM内存,供运行在SM上的线程块共享使用。共享内存的设计思路是,如果多个线程需要处理相同的数据,只需要其中一个线程从全局内存(global memory)加载,而其他线程将共享这一数据。合理使用共享内存可以减少从全局内存加载重复数据的操作,并提高内核执行性能。共享内存还可以用作线程块(block)内的线程之间的同步机制。
-
L1缓存:每个SM还拥有一个L1缓存,它可以缓存从L2缓存中频繁访问的数据。
-
L2缓存:所有SM都共享一个L2缓存,它用于缓存全局内存中被频繁访问的数据,以降低时延。需要注意的是,L1和L2缓存对于SM来说是公开的,也就是说,SM并不知道它是从L1还是L2中获取数据。SM从全局内存中获取数据,这类似于CPU中L1/L2/L3缓存的工作方式。
-
全局内存:GPU还拥有一个片外全局内存,它是一种容量大且带宽高的动态随机存取存储器(DRAM)。例如,NVIDIA H100拥有80 GB高带宽内存(HBM),带宽达每秒3000 GB。由于与SM相距较远,全局内存的时延相当高。然而,芯片上还有几个额外的存储层以及大量的计算单元有助于掩饰这种时延。
现在我们已经了解GPU硬件的关键组成部分,接下来我们深入一步,了解执行代码时这些组件是如何发挥作用的。
3
了解GPU的执行模型
要理解GPU如何执行kernel,我们首先需要了解什么是kernel及其配置。
CUDA Kernel与线程块简介
CUDA是NVIDIA提供的编程接口,用于编写运行在其GPU上的程序。在CUDA中,你会以类似于C/C++函数的形式来表达想要在GPU上运行的计算,这个函数被称为kernel。kernel在并行中操作向量形式的数字,这些数字以函数参数的形式提供给它。一个简单的例子是执行向量加法的kernel,即接受两个向量作为输入,逐元素相加,并将结果写入第三个向量。
要在GPU上执行kernel,我们需要启用多个线程,这些线程总体上被称为一个网格(grid),但网格还具有更多的结构。一个网格由一个或多个线程块(有时简称为块)组成,而每个线程块又由一个或多个线程组成。
线程块和线程的数量取决于数据的大小和我们所需的并行度。例如,在向量相加的示例中,如果我们要对256维的向量进行相加运算,那么可以配置一个包含256个线程的单个线程块,这样每个线程就可以处理向量的一个元素。如果数据更大,GPU上也许没有足够的线程可用,这时我们可能需要每个线程能够处理多个数据点。
图4:线程块网格。引自《CUDA C++编程指南》(NVIDIA)
编写一个kernel需要两步。第一步是运行在CPU上的主机代码,这部分代码用于加载数据,为GPU分配内存,并使用配置的线程网格启动kernel;第二步是编写在GPU上执行的设备(GPU)代码。
对于向量加法示例,下图显示了主机代码。
图 5:CUDA kernel的主机代码,用于将两个向量相加。
下图为设备代码,它定义了实际的kernel函数。
图 6:包含向量相加kernel定义的设备代码。
由于本文的重点不在于教授CUDA,因此我们不会更深入地讨论此段代码。现在,让我们看看在GPU上执行kernel的具体步骤。
4
在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以实现并行执行。
图 7:将线程块分配给SM
由于SM的数量有限,而大型kernel可能包含大量线程块,因此并非所有线程块都可以立即分配执行。GPU会维护一个待分配和执行的线程块列表,当有任何一个线程块执行完成时,GPU会从该列表中选择一个线程块执行。
3. 单指令多线程 (SIMT) 和线程术(Warp)
众所周知,一个块(block)中的所有线程都会被分配到同一个SM上。但在此之后,线程还会进一步划分为大小为32的组(称为warp[2]),并一起分配到一个称为处理块(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执行的全部内容,但还有一点值得讨论:动态资源分区。
5
资源划分和占用概念
我们通过一个称为“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是一项复杂任务。我们必须合理分配资源,在保持高占用率的同时尽量降低时延。例如,拥有大量寄存器可以加快代码的运行速度,但可能会降低占用率,因此谨慎优化代码至关重要。
6
总结
我理解众多的新术语和新概念可能令读者望而生畏,因此文章最后对要点进行了总结,以便快速回顾。
-
GPU由多个SM组成,每个SM又包含多个处理核心。
-
GPU上存在着一个片外全局内存,通常是高带宽内存(HBM)或动态随机存取内存(DRAM)。它与芯片上的SM相距较远,因此时延较高。
-
GPU中有两个级别的缓存:片外L2缓存和片上L1缓存。L1和L2缓存的工作方式类似于CPU中的L1/L2缓存。
-
每个SM上都有一小块可配置的共享内存。这块共享内存在处理核心之间共享。通常情况下,线程块内的线程会将一段数据加载到共享内存中,并在需要时重复使用,而不是每次再从全局内存中加载。
-
每个SM都有大量寄存器,寄存器会根据线程需求进行划分。NVIDIA H100每个SM有65536个寄存器。
-
在GPU上执行kernel时,我们需要启动一个线程网格。网格由一个或多个线程块组成,而每个线程块又由一个或多个线程组成。
-
根据资源可用性,GPU会分配一个或多个线程块在SM上执行。同一个线程块中的所有线程都会被分配到同一个SM上执行。这样做的目的是为了充分利用数据的局部性(data locality),并实现线程之间的同步。
-
被分配给SM的线程进一步分为大小为32的组,称为warp。一个warp内的所有线程同时执行相同的指令,但在数据的不同部分上执行(SIMT)(尽管新一代GPU也支持独立的线程调度)。
-
GPU根据每个线程的需求和SM的限制,在线程之间进行动态资源划分。程序员需要仔细优化代码,以确保在执行过程中达到最高的SM占用率。
脚注
[1]没错,得益于超线程技术和多核处理器,CPU也可以并行执行任务。但长期以来,大量工作都致力于提高顺序执行的性能。
[2]在当前一代的NVIDIA GPU中,warp大小为32。但在未来的硬件迭代中,这个大小可能会发生改变。
其他人都在看
试用OneFlow: github.com/Oneflow-Inc/oneflow/