Dissecting and Modeling the Architecture of Modern GPU Cores

作者/机构: RODRIGO HUERTA, Polytechnic University of Catalonia, Barcelona, Barcelona, Spain; MOJTABA ABAIE SHOUSHTARY, Polytechnic University of Catalonia, Barcelona, Barcelona, Spain; JOSÉ LORENZO CRUZ, Polytechnic University of Catalonia, Barcelona, Barcelona, Spain; ANTONIO GONZÁLEZ, Polytechnic University of Catalonia, Barcelona, Barcelona, Spain

A1 主要贡献

本文旨在弥合学术界GPU模拟器与现代商业GPU架构之间的差距。当前大多数学术研究依赖的模拟器所模拟的GPU核心架构基于超过15年前的设计(如Tesla微架构),与现代核心架构存在显著差异。本文通过逆向工程揭示了现代NVIDIA GPU核心架构的关键特性,并将其集成到一个先进的GPU模拟器中,从而显著提高了模拟的准确性。

核心问题与研究目标
- 问题:学术界使用的GPU模拟器(如Accel-sim)基于过时的架构模型,导致在评估新思想时其研究结果可能与现代硬件上的表现不符。
- 目标:通过逆向工程揭示现代NVIDIA GPU(Turing、Ampere、Blackwell)核心的关键微架构细节,并更新最先进的GPU模拟器,以提供一个更接近真实硬件的、更准确的基线模型,从而帮助研究人员更好地识别未来GPU的挑战与机遇。

创新点与主要贡献
- 揭示指令分发阶段的操作:详细阐述了依赖处理机制、warp的就绪条件以及分发调度器策略。
- 提出取指阶段及其调度器的合理操作模型:描述了取指阶段的可能工作方式。
- 提供寄存器文件及其缓存的关键细节:展示了现代NVIDIA GPU不使用操作数收集阶段或收集器单元。
- 揭示数据和指令内存流水线的多个细节
- 重新设计并实现模拟器核心模型:从零开始重新设计了Accel-sim模拟器中的SM/核心模型,并集成了本文所有发现。
- 高精度验证:针对包括最新NVIDIA架构(Blackwell)在内的真实硬件验证了新模型的准确性。与真实硬件相比,新模型的平均绝对百分比误差(MAPE)为13.45%,而原模拟器模型的MAPE为34.03%。对于Blackwell架构,实现了17.41%的MAPE。
- 对比依赖管理机制:证明了现代GPU中基于软件的依赖管理系统在性能和硬件成本方面优于传统的基于记分板的硬件机制。

A3 背景知识与动机

大多数学术界的GPU架构研究依赖于GPGPU-Sim模拟器实现的模型【1, 18】。最近,该模拟器更新以包含Volta架构中开始使用的子核心(NVIDIA术语中的处理块)。图1展示了该模拟器中建模的架构框图。我们可以看到它包含四个子核心和一些共享组件,如L1指令缓存、L1数据缓存、共享内存和纹理单元。下面,我们总结了这个模型的一些关键组件。


图1:Accel-sim的SM/核心模型。

在取指阶段,一个轮询调度器选择四个warp,并为每个warp从L1指令缓存中请求两条指令。一个warp如果其指令缓冲区为空,就有资格被选中。该模型不考虑warp属于哪个子核心,并且大多数指令被建模为在同一个周期内完成取指和译码阶段【43】。指令缓冲区是每个warp私有的缓冲区,用于存储取指和译码后的指令。指令会一直留在这个缓冲区中,直到它们准备就绪并被选中分发。

在分发阶段,调度器遵循“贪婪后最老”(Greedy Then Oldest, GTO)【85】策略,在那些没有等待栅栏且其最老指令已就绪的warp中选择一个。每个warp有两个记分板用于检查数据依赖。第一个记分板标记待写入寄存器的操作,以跟踪WAW和RAW依赖。只有当一条指令的所有操作数在该记分板中都被清除时,它才能被分发。第二个记分板计算寄存器的在途消费者数量,以防止WAR风险【63】。第二个记分板是必要的,因为尽管指令是按序分发的,但由于假设存在操作数收集单元,它们的操作数可能会被乱序取回,这可能会延迟源操作数的读取。此外,内存指令在分发后被发送到操作数收集中,并可能在一条更年轻的算术指令写入其结果之后才读取其源操作数。

一旦指令被分发,它被放置在一个收集器单元(Collector Unit, CU)中以检索其源寄存器操作数。每个子核心都有一个私有的寄存器文件,该文件有多个可以并行访问的存储体(bank)。一个仲裁器处理对同一存储体的多个请求可能产生的冲突。当一条指令的所有源操作数都在CU中时,该指令进入派发(Dispatch)阶段,在此阶段它被分配给适当的执行单元(例如,内存、单精度、特殊功能),其延迟根据单元类型和指令而异。执行后,指令到达写回阶段,其结果被写入寄存器文件。

这个在Accel-sim【53】中建模的GPU微架构类似于基于Tesla【55】的NVIDIA GPU(2006年发布),并更新了一些现代特性,主要是一个子核心模型和类似于Volta【53】的、使用IPOLY【83】索引的分区缓存。然而,它缺少现代NVIDIA GPU中存在的一些重要组件,例如L0指令缓存【21, 29, 70, 71, 73–76】和统一寄存器文件【21】。此外,子核心的一些主要组件,如分发逻辑、寄存器文件或寄存器文件缓存等,没有被更新以反映当前的设计。

开发一个精确的现代GPU核心模型的主要挑战是,这些细节大多未被公开。本工作旨在逆向工程现代NVIDIA GPU核心的微架构,并更新Accel-sim以融入所揭示的特性。这将使得这个更新后的Accel-sim模拟器的用户能够从更接近于工业界在商业设计中已证明成功的基础模型开始,从而使他们的工作更具相关性。

逆向工程方法论

本节解释了我们用于发现NVIDIA Blackwell、Ampere和Turing GPU核心(SMs)架构的研究方法。

我们的方法是基于编写小型微基准测试程序,并在真实硬件上执行时测量它们的执行时间。经过的周期数是通过在代码前后插入指令来获取的,这些指令将GPU的CLOCK计数器保存到一个寄存器中,并将其存储在主内存中以供后续处理。被评估的指令序列通常由手写的SASS指令(NVIDIA汇编语言)组成,包括它们的控制位。我们分析记录的周期数,以证实或反驳关于控制位语义或微架构中特定特性的特定假设。下面给出两个例子来说明这种方法:

列表1:用于检查寄存器文件读取冲突的代码。

尽管NVIDIA没有官方工具可以直接编写SASS代码,但各种第三方工具允许程序员重新排列和修改SASS汇编代码(包括控制位)。这些工具被用于在编译器生成的代码不是最优时优化关键内核的性能。MaxAS【37】是第一个用于修改SASS二进制文件的工具。后来,为Kepler架构开发了其他工具,如KeplerAS【102, 103】。然后,出现了TuringAS【99】和CUAssembler【30】以支持更新的架构。在本工作中,我们使用CUAssembler,因为它具有灵活性、可扩展性,并支持广泛的现代NVIDIA架构。

方法细节

现代NVIDIA GPU架构中的控制位

现代NVIDIA GPU架构的机器指令包含一些位,编译器/程序员必须适当地设置这些位以在运行时处理数据依赖【67】。在现代NVIDIA架构中,数据依赖不是由记分板管理的,而是由编译器负责引导硬件以保证这些依赖得到遵守。此外,机器指令还包括一些其他位,编译器/程序员必须相应地设置这些位来管理寄存器文件缓存,从而提高性能并减少能耗。这些位被称为控制位。

下面,我们描述这些控制位的行为。一些文档【30, 37, 46, 47】描述了它们,但这些文档通常含糊不清或不完整,因此我们使用第3节中描述的方法来揭示这些控制位的语义,并验证它们的行为如下所述。

每个子核心每个周期可以分发一条指令。默认情况下,分发调度器(Issue Scheduler)会尝试分发同一warp的指令,前提是该warp中程序顺序上最旧的指令已经就绪。编译器通过控制位来指示一条指令何时可以准备好被分发。如果在前一个周期分发了指令的warp,其最旧的指令尚未就绪,那么分发逻辑会根据5.1小节中描述的策略,从另一个warp中选择一条指令。

数据依赖的处理方式取决于产生者指令是固定延迟还是可变延迟。对于固定延迟的依赖关系,每个warp都有一个称为Stall计数器的计数器。如果该计数器不为零,则该warp不允许分发指令。该计数器可以通过每条指令中的一些控制位设置为特定值。对于每个依赖关系,编译器在产生者指令中设置这些控制位,其值为其延迟减去产生者和第一个消费者之间的指令数。所有这些每个warp的Stall计数器每周期减一,直到达到0。分发逻辑仅检查此计数器,并且不会考虑任何计数器不为零的warp。例如,一个延迟为四个周期的加法指令,其第一个消费者是下一条指令,它会在Stall计数器中编码一个四。每条指令可以在Stall计数器中编码的最大值为15。

在列表2中,我们展示了用于揭示Stall计数器语义的代码。R1、R2、R3和R4的值被设置为1,以建立一个可控的输出。实验的核心是测量由NOP指令包围的代码区域所经过的时钟周期(如第3节所述),以及FFMA指令的结果。在这个区域内,一条FADD指令紧跟着一条数据依赖的FFMA。最后一条指令将时钟值(R14和R24)和R5的内容传输到内存以验证计算。我们运行了这个代码的多个版本,改变了第四个FADD的Stall计数器的值,并检查了经过的时间和最后一个FFMA的结果。我们观察到,如果我们将目标Stall计数器设置为1,两个时钟之间测量到的经过时间是5。然而,在R5中得到的结果是2,这是错误的。另一方面,如果我们将这个Stall计数器设置为4,时钟之间计算出的经过时间是8,并且R5的内容是6,正如预期的那样。请注意,第三个FADD的Stall计数器设置为2,以避免与第四个FADD的数据依赖。


列表2:用于分析Stall计数器行为的代码。

使用第3节中解释的方法,我们已经验证了如果Stall计数器没有按照上述方案正确设置,程序的运行结果是不正确的。这证明了硬件不检查RAW(写后读)风险,而仅仅依赖于这些由编译器设置的计数器。

我们观察到一种特殊行为,当Stall计数器超过11而Yield位(稍后介绍)设置为0时:warp仅停顿一到两个周期。这种反直觉的组合从未出现在编译器生成的代码中,是我们实验中手动设置控制位生成的。我们还发现了一个特殊场景——由ERRBAR指令和紧跟在内核EXIT之后的自跳转触发——其中Stall计数器设置为0,Yield位设置为1。在这些条件下,warp会精确地停顿45个周期,然后才分发其下一条指令。

这种编译器辅助处理数据依赖的机制所需的硬件比传统的记分板方法更少,能耗也更低,因为它不需要一个带有寄存器状态的硬件表,也不需要从分发逻辑到记分板的连线。

当产生者指令具有可变延迟时(例如,内存、特殊功能指令),编译器不知道其执行时间,因此这些风险不能通过Stall计数器来处理。在这种情况下,使用依赖计数器(Dependence counters)。每个warp有六个特殊寄存器来存储这些计数器,它们被称为SBx,其中x的取值范围是[0-5]。这些寄存器中的每一个都可以存储0到63范围内的值,并且在warp开始时被初始化为零。在每条指令中,有一些控制位用于指示最多两个在分发后增加的计数器。其中一个计数器在写回时减少(用于处理RAW和WAW依赖),另一个在寄存器读取时减少(用于处理WAR依赖)。为此,每条指令都有两个各3位的字段来指示这两个计数器。此外,每条指令都有一个6位的掩码,用于指示它需要检查哪些依赖计数器来确定是否准备好分发。我们称这个掩码为依赖计数器掩码(Dependence counters mask)。请注意,一条指令最多可以检查所有六个计数器。依赖于较早的可变延迟指令的指令,在其依赖计数器掩码中将相应的依赖计数器设置为1,并会一直停顿直到这些计数器达到零。

请注意,如果一条指令有多个源操作数,其产生者都具有可变延迟,那么所有这些产生者可以使用同一个依赖计数器而不会损失任何并行性。另一方面,在存在超过六个产生者指令,且它们各自的消费者指令都比最年轻的产生者指令更年轻(在程序顺序上)的场景中,编译器有两种选择。首先,它可以尝试重新排序代码以避免这种情况;否则,一些依赖计数-器必须由多个产生者-消费者对共享。请注意,在这种情况下,可能会损失一些性能,因为程序顺序中的第一个消费者需要等待与其计数器相关的所有产生者完成,而不仅仅是等待其特定的产生者。

依赖计数器的增加是在产生者指令分发后的一个周期执行的,因此它要在一个周期后才生效。因此,如果消费者是下一条指令,产生者必须将Stall计数器设置为2,以避免在下一个周期分发消费者指令。

检查这些计数器值的另一种方法是通过DEPBAR.LE指令。例如,DEPBAR.LE SB1, 0x3, {4,3,2}要求依赖计数器SB1的值小于或等于3才能继续执行。最后一个参数([, {4,3,2}])是可选的,如果使用,指令必须等到由这些ID(本例中为4、3、2)指定的依赖计数器的值都为0时才能分发。DEPBAR.LE在某些特定场景中特别有用。例如,当一个消费者需要等待一个序列中的前m条指令时,它允许对一个序列中的n条按序写回的可变延迟指令(例如,带有STRONG.SM修饰符的内存指令)使用相同的依赖计数器。使用一个参数等于n - m的DEPBAR.LE可以使该指令等待序列中的前m条指令。该指令有用的另一个例子是重用同一个依赖计数器来保护RAW/WAW和WAR风险。如果一条指令对两种类型的风险使用相同的依赖计数器,由于WAR风险比RAW/WAW解决得早,随后的DEPBAR.LE SBx, 0x1将等到WAR解决后允许warp继续执行。稍后消耗其结果的指令需要等到该依赖计数器变为零,这意味着结果已经被写入。根据我们的观察,为确保DEPBAR.LE能有效停顿后续指令,其Stall计数器必须至少设置为4。

一个使用可变延迟产生者处理依赖的例子可以在图2中找到。这段代码展示了一个包含七条指令的序列及其相关编码,以及一个时间线,显示了通过控制位管理可变延迟依赖的关键流水线组件的演变。前三条指令是加载指令(可变延迟指令),它们使用依赖计数器来防止数据风险。第一条(0x30)和第二条(0x40)指令增加了SB3,该计数器将在写回时减少。在时间线表格中,我们可以看到这个依赖计数器在这些指令到达控制(Control)阶段(在5.1小节介绍)后增加,分别在第三和第四个周期,达到值2。指令0x40和0x50增加了SB0,该计数器将在每条指令读取源操作数后减少。第一个加法指令(0x60)是一条没有任何依赖的指令,用于说明由于第三个加载指令(0x50)的Stall计数器设置为2而导致它如何被停顿。第二个加法指令(0x80)与第二个加载指令(0x40)有WAR

图2:使用依赖计数器处理依赖的示例。

依赖关系。尽管WAR依赖可以通过在其依赖计数器掩码中设置SB0来处理,但这会使指令停顿,直到第三个加载(0x50)的读取也完成,这是不必要的。因此,我们使用DEPBAR指令来等待SB0的值小于或等于1。最后,最后一个加法指令(0x90)与第一个加载(0x30)有RAW依赖,与第三个加载(0x50)有WAR依赖。因此,依赖计数器掩码编码了在分发前,SB0和SB3必须为0。注意,指令0x50也使用SB4来控制与未来指令的RAW/WAR风险,但指令0x90不等待这个依赖计数器,因为它与那个加载没有RAW依赖。

在读取源操作数后清除WAR依赖是一项重要的优化,因为源操作数有时比结果产生要早得多,特别是对于内存指令。例如,在这个例子中,指令0x80等到指令0x70确保与R2没有WAR依赖,而不是等到指令0x50执行其读取操作,这可能会在很多周期之后发生并且是不必要的。

还有一个控制位叫做Yield,用于向硬件指示在下一个周期不应分发同一warp的指令。如果子核心的其他warp在下一个周期没有准备好,就不会分发任何指令。每条指令都编码了Stall计数器和Yield位。如果Stall计数器设置为大于一的值,warp将至少停顿一个周期,所以在这种情况下,无论Yield是否设置都无所谓。

此外,GPU拥有一个寄存器文件缓存,可以节省能源并减少寄存器文件读取端口的争用。这个结构由软件通过为每个源操作数添加一个控制位——复用位(reuse bit)来管理,该位向硬件指示是否缓存寄存器的内容。关于寄存器文件缓存的更多细节将在后面的5.3.1节中解释。

尽管本文关注NVIDIA架构,但其他供应商(如AMD)的GPU也依赖于软硬件协同设计来管理依赖关系和提升性能【6–16】。与NVIDIA的DEPBAR.LE指令类似,AMD采用waitcnt指令;根据架构不同,每个wavefront(warp)有三个或四个计数器来处理可变延迟数据依赖,每个计数器专用于特定指令类型。与NVIDIA不同,AMD ISA的常规指令不包含指定等待某个计数器归零的字段,而是数据依赖需要显式的waitcnt指令,这增加了指令数量。这种方法降低了解码复杂性,但增加了总指令数。对于ALU指令,数据风险完全由硬件管理。此外,AMD ISA包含一个DELAY_ALU指令,编译器可以有选择地使用它来优化这些指令的停顿,方法是阻止后续指令在一定周期内分发。相反,NVIDIA依赖编译器通过为固定延迟的产生者设置Stall计数器来正确处理数据依赖,从而导致指令数减少但解码开销增加。

GPU核心的微架构

在本节中,我们使用第3节中解释的方法,描述我们关于现代商业NVIDIA GPU核心微架构的发现。图3展示了GPU核心微架构的主要组件。下面,我们详细描述分发调度器、前端、寄存器文件和内存流水线的微架构。

5.1 分发调度器

为了描述现代NVIDIA GPU的分发调度器,我们首先介绍在每个周期哪些warp被视为可分发的候选者,然后介绍选择其中一个的策略。

5.1.1 Warp就绪条件

Warp按程序顺序分发其指令。一个warp在给定周期内成为分发其最老指令的候选者,需要满足一些条件。这些条件取决于同一warp的先前指令和核心的全局状态。

一个明显的条件是在指令缓冲区中有一条有效的指令。此外,warp的最老指令不能与同一warp中尚未完成的更老指令有任何数据依赖风险。指令间的依赖关系通过软件支持,即通过上文第4节中描述的控制位来处理。

此外,对于固定延迟的指令,一个warp只有在能够保证其执行所需的所有资源在分发后都可用时,才能在给定周期成为分发其最老指令的候选者。


图3:本工作推断的现代NVIDIA GPU SM/核心设计。

其中一个资源是执行单元。执行单元有一个输入锁存器,当指令到达执行阶段时,该锁存器必须是空闲的。如果执行单元的宽度只有半个warp,则该锁存器被占用两个周期;如果其宽度是一个完整的warp,则为一个周期。

对于源操作数是内存常量地址空间中操作数的指令,它们访问L0固定延迟(L0 FL)常量缓存,并且标签查找在分发阶段执行。如果操作数不在缓存中,调度器在未命中得到服务之前不会分发任何指令。但是,如果在四个周期后未命中仍未被服务,则调度器会切换到另一个warp(具有就绪指令的最年轻的warp)。

至于寄存器文件读取端口的可用性,分发调度器不知道指令在接下来的周期中是否有足够的端口进行无停顿的读取。通过观察列表1所示代码中的寄存器文件端口冲突,在移除最后一个FFMA后的NOP时,并未停顿第二个CLOCK的分发,从而证实了这一点。我们进行了大量实验来揭示分发和执行之间的流水线结构,但未能找到一个能完美拟合所有实验的模型。然而,我们下面描述的模型在几乎所有情况下都是正确的,因此我们假设使用该模型。在该模型中,固定延迟指令在分发阶段和读取源操作数的阶段之间有两个中间阶段。

第一个阶段,我们称之为控制(Control),对固定和可变延迟指令都是共有的,其职责是增加依赖计数器或在需要时读取时钟计数器的值。根据我们的实验证实,这导致增加依赖计数器的指令和等待该依赖计数器为0的指令之间至少需要一个周期的间隔,以使该增加可见,因此除非第一条指令设置了Yield位或大于一的Stall计数器,否则两条连续的指令不能使用依赖计数器来避免数据依赖风险。

第二个阶段仅存在于固定延迟指令中。在这个阶段,会检查寄存器文件读取端口的可用性,指令会在此阶段停顿,直到保证它可以无寄存器文件端口冲突地继续进行。我们称这个阶段为分配(Allocate)。关于寄存器文件读写流水线及其缓存的更多细节在5.3小节中提供。

可变延迟指令(例如,内存指令)在经过控制阶段后直接被送到一个队列(不经过分配阶段)。当保证不会有任何冲突时,该队列中的指令被允许进入寄存器文件读取流水线。固定延迟指令在分配寄存器文件端口时优先于可变延迟指令,因为它们需要在分发后固定的周期数内完成,以保证代码的正确性,因为依赖关系如上所述由软件处理。

5.1.2 调度策略

为了发现分发调度器的策略,我们开发了许多涉及多个warp的不同测试用例,并通过分发调度器记录了在每个周期中选择了哪个warp进行分发。这些warp执行一小段指令序列,并改变其Yield和Stall计数器控制位的值。通过允许保存GPU当前CLOCK周期的指令,我们识别出了在特定周期中被选中分发的warp。由于硬件不允许连续分发两条这样的指令,我们在它们之间使用了受控数量的其他指令(通常是NOPs)。

我们的实验使我们得出结论,分发调度器使用一种贪婪策略,即如果同一warp的指令满足上述资格标准,就从中选择一条指令。如果该warp不符合资格,则调度器从满足资格标准的最年轻的warp中选择一条指令。我们将这种分发调度器策略称为编译器指导的贪婪后最年轻(Compiler Guided Greedy Then Youngest, CGGTY),因为编译器通过控制位(Stall计数器、Yield和依赖计数器)来辅助调度器。

图4中我们的一些实验示例说明了这种分发调度器策略。该图描绘了在同一子核心中执行四个warp时,三种不同情况下指令的分发情况。每个warp执行相同的代码,由32条独立的指令组成,如果该warp单独运行,这些指令可以每周期分发一条。

在第一种情况,图4 ○a 中,所有的Stall计数器、依赖掩码和Yield位都设置为零。从图中我们可以看到,调度器从最年轻的warp,即W3开始分发指令,直到它在指令缓存(Icache)中发生未命中。由于未命中,W3没有任何有效指令,因此调度器切换到从W2分发指令。W2在Icache中命中,因为它重用了W3带来的指令,并且当它到达W3未命中的点时,未命中已经被服务,所有剩余的指令都在Icache中找到,因此调度器贪婪地分发该warp直到结束。之后,调度器继续从W3(最年轻的warp)分发指令直到结束,因为现在所有的指令都在Icache中。然后,调度器切换到从头到尾分发W1的指令,最后,它对W0(最老的warp)也这样做。


图4:三种不同场景下,四个不同warp的指令分发时间线。

图4 ○b 展示了当每个warp的第二条指令将其Stall计数器设置为四时的指令分发时间线。我们可以观察到,调度器在两个周期后从W3切换到W2,再过两个周期切换到W1,然后再过两个周期切换回W3(因为W3的Stall计数器已变为零)。一旦W3、W2和W1完成,调度器开始从W0分发。在分发W0的第二条指令后,调度器产生了四个气泡,因为没有其他warp可以隐藏由Stall计数器施加的延迟。

图4 ○c 显示了当每个warp的第二条指令中设置了Yield时的调度器行为。我们可以看到,在分发每个warp的第二条指令后,调度器会切换到其余warp中最年轻的一个。例如,W3切换到W2,而W2又切换回W3。我们还测试了一个设置了Yield且没有更多warp可用的场景(未在此图中显示),我们观察到调度器产生了一个周期的气泡。

5.2 前端

根据多个NVIDIA文档【21, 29, 70, 71, 73–76】中的图表,SM(流多处理器)有四个不同的子核心,warp以轮询方式(即 warp ID % 4)【46, 47】均匀地分配给这些子核心。每个子核心都有一个私有的L0指令缓存,该缓存连接到一个由SM的所有四个子核心共享的L1指令缓存。我们假设有一个仲裁器来处理不同子核心的多个请求。

每个L0指令缓存都有一个指令预取器【72】。我们的实验证实了Cao等人【25】的先前研究,该研究表明指令预取在GPU中是有效的。尽管我们未能确认NVIDIA GPU中使用的具体设计,但我们怀疑它是一种简单的方案,如流缓冲区(stream buffer)【50】,在发生未命中时预取连续的内存块。根据我们的分析,我们假设流缓冲区大小为8,具体细节见7.3小节。

我们无法通过实验确认确切的指令取指策略,但它必须与分发策略相似;否则,在指令缓冲区中找不到有效指令的情况会相对频繁地发生,而我们在实验中并未观察到这一点。基于此,我们假设每个子核心每个周期可以取指和译码一条指令。取指调度器尝试从前一个周期(或分发了指令的最近一个周期)分发的同一warp中取指指令,除非它检测到指令缓冲区中已有的指令数加上其在途的取指数等于指令缓冲区的大小。在这种情况下,它会切换到其指令缓冲区中有空闲条目的最年轻的warp。我们假设每个warp的指令缓冲区有三个条目,因为考虑到从取指到分发有两个流水线阶段,这足以支持分发调度器的贪婪特性。如果指令缓冲区大小为二,分发调度器的贪婪策略将失败。例如,假设一个场景,指令缓冲区大小为二,所有请求都在指令缓存中命中,所有warp的指令缓冲区都已满,在周期1,一个子核心正在从warp W1分发指令并从W0取指。在周期2,W1的第二条指令将被分发,第三条指令将被取指。在周期3,W1的指令缓冲区中将没有指令,因为指令3仍在译码阶段。因此,分发调度器将选择另一warp的指令。请注意,如果指令缓冲区中有三个条目,这种情况不会发生,我们的实验证实了这一点。请注意,文献中大多数先前的设计通常假设取指和译码宽度为两条指令,每个warp的指令缓冲区有两个条目。此外,那些设计仅在指令缓冲区为空时才取指指令。因此,贪婪的warp至少在连续两条指令后总会改变,这与我们的经验观察不符。

5.3 寄存器文件

现代NVIDIA GPU拥有多种寄存器文件:

我们通过运行不同组合的SASS汇编指令进行了大量实验,以揭示寄存器文件的组织结构。例如,我们编写了对寄存器文件端口压力不同、使用或不使用寄存器文件缓存的代码。

我们的实验揭示,与先前的工作【1, 19, 53】假设存在操作数收集器来处理寄存器文件端口冲突不同,现代NVIDIA GPU并不使用它。操作数收集器单元会给分发和写回之间的时间带来可变性,使得无法像第4节为固定延迟指令解释的那样正确处理依赖关系,因为其执行延迟必须在编译时就知道。我们通过检查特定产生者-消费者指令序列的正确性,改变在同一存储体中的操作数数量以引起不同数量的寄存器文件端口冲突,从而证实了操作数收集器的缺席。我们观察到,无论寄存器文件端口冲突的数量如何,为避免数据风险而在指令的Stall计数器字段中所需的值保持不变。

另一个发现是每个寄存器文件存储体只有一个1024位的写端口。此外,当一条加载指令和一条固定延迟指令在同一周期完成时,被延迟一个周期的是加载指令。另一方面,当两条固定延迟指令之间存在冲突时,例如,一条HADD2(延迟5个周期)后跟一条使用相同目标存储体的FFMA(延迟4个周期),尽管它们同时完成,但它们都不会被延迟。这意味着使用了类似于在Fermi【66】中为固定延迟指令引入的结果队列。这些指令的消费者不会被延迟,这表明使用了旁路(bypassing)来在结果被写入寄存器文件之前将其转发给消费者。此外,列表3中的代码证明了结果队列和/或旁路机制的存在,以及编译器对它们的感知。在这段代码中,要加载的值的地址最初存储在寄存器R16和R17中;由于地址是49位宽,需要两个寄存器。我们观察到,Stall计数器值为4足以使第二条MOV指令正确执行,确保R43中的值能及时被最后的MOV消耗。然而,第三条MOV需要至少为5的Stall计数器值;否则,程序会导致非法内存访问错误。这种行为表明,结果队列和/或旁路对固定延迟指令可用,但对可变延迟指令不可用,后者需要一个额外的周期才能正确执行。

关于读取,我们观察到每个存储体的带宽为1024位。这些测量是通过各种测试获得的,这些测试记录了连续的FADD、FMUL和FFMA指令的经过时间。例如,两个源操作数在同一存储体的FMUL指令会产生一个1周期的气泡,而如果两个操作数在不同的存储体,则没有气泡。所有三个源操作数都在同一存储体的FFMA指令会产生一个2周期的气泡。

不幸的是,我们无法找到一个能够匹配我们研究过的各种代码序列的读取策略,因为我们观察到气泡的产生有时取决于指令的类型和每个操作数在指令中的角色(例如,加数与被乘数)。我们发现,最接近且能匹配我们测试的几乎所有实验的方案是,在指令分发和固定延迟指令的操作数读取之间有两个中间阶段,我们称之为控制(Control)分配(Allocate)。前者已在5.1.1小节中解释过。后者负责预留寄存器文件读取端口。寄存器文件的每个存储体都有一个1024位的读取端口,读取冲突通过使用寄存器文件缓存来缓解(更多细节稍后介绍)。我们的实验表明,所有固定延迟指令都花费三个周期来读取源操作数,即使在其中某些周期中,指令是空闲的(例如,当只有两个源操作数时)。例如,FADD和FMUL的延迟与FFMA相同,尽管它们少一个操作数,并且FFMA的延迟始终相同,无论其三个操作数是否在同一存储体中。如果分配阶段的指令意识到它在接下来的三个周期内无法读取其所有操作数,它将停留在这个阶段(向上阻塞流水线)并产生气泡,直到它能预留到在未来三个周期内读取源操作数所需的所有端口。

5.3.1 寄存器文件缓存

在GPU中使用寄存器文件缓存(RFC)已被证明可以缓解寄存器文件端口的争用并节省能源【2, 31, 33, 34, 86】。

通过我们的实验,我们观察到NVIDIA寄存器文件缓存的设计与Gebhart等人【34】的工作类似。与该设计一致,RFC由编译器控制,并且仅由操作数在常规寄存器文件中的指令使用。上面提到的结果队列的行为类似于最后结果文件(Last Result File)结构。然而,与上述引用的论文不同,这里没有使用两级分发调度器,正如在5.1.2小节中所解释的。

关于RFC的组织,我们的实验表明,在每个子核心中,两个寄存器文件存储体各有其一个条目。每个条目存储三个1024位的值,每个值对应于指令可能具有的三个常规寄存器源操作数之一。总的来说,RFC的总容量是六个1024位的操作数值(子条目)。请注意,有些指令的某些操作数需要两个连续的寄存器(例如,张量核心指令)。在这种情况下,这两个寄存器中的每一个都来自不同的存储体,并被缓存到它们相应的条目中。

编译器管理缓存分配策略。当一条指令被分发并读取其操作数时,如果编译器为该操作数设置了复用位,则每个操作数都会被存储在RFC中。如果后续指令来自同一个warp,寄存器ID与RFC中存储的ID匹配,并且操作数在指令中的位置与触发缓存的指令中的位置相同,那么该后续指令将从RFC中获取其寄存器源操作数。一个缓存的值在对同一存储体和操作数位置的读取请求到达后变得不可用,无论它是否在RFC中命中。这在列表4的示例2中有所说明;为了让第三条指令在缓存中找到R2,第二条指令必须设置R2的复用位,尽管R2对于第二条指令来说已经在缓存中了。列表4展示了另外三个例子来说明RFC的行为。


列表4:寄存器文件缓存行为。

5.4 内存流水线

现代NVIDIA GPU中的内存流水线有一些初始阶段是每个子核心局部的,而执行内存访问的最后阶段则由四个子核心共享,因为数据缓存和共享内存是它们共享的【21, 29】。在本节中,我们探究每个子核心中加载/存储队列的大小,每个子核心向共享内存结构发送请求的速率,以及不同内存指令的延迟。

请注意,主要有两种类型的内存访问,一种是访问共享内存(SM本地内存,由一个块中的所有线程共享),另一种是访问全局内存(GPU主内存)。

为了探究队列的大小和内存带宽,我们进行了一系列实验,在这些实验中,每个子核心要么执行一个warp,要么处于空闲状态。每个warp执行一系列独立的加载或存储指令,这些指令总是命中数据缓存或共享内存,并使用常规寄存器。表1显示了这些实验在Ampere GPU上的结果。第一列显示了代码中的指令编号,接下来的四列显示了在四种子核心活动数量不同的场景中,该指令在每个核心中被分发的周期。

我们可以观察到,每个子核心可以连续五个内存指令每周期分发一条。第六条内存指令的分发会被停顿若干周期,具体周期数取决于活动子核心的数量。如果我们看有多个活动子核心的情况,我们可以看到每个子核心的第六条及后续指令每隔两个周期分发一次。


表1:每条内存指令被分发的周期。每个单元格存储了所有活动子核心的周期。

从这些数据中,我们可以推断出每个子核心可以无停顿地缓冲最多五条连续指令,并且全局结构可以每两个周期从任何一个子核心接收一个内存请求。我们还可以推断出,每个子核心中完成的地址计算吞吐量为每四个周期一条指令,这由单个活动子核心时第六条指令分发后出现的4个周期的间隔所证明。当两个子核心活动时,每个子核心可以每4个周期分发一条内存指令,因为共享结构可以每两个周期处理一条指令。当更多子核心活动时,共享结构成为瓶颈。例如,当四个子核心活动时,每个子核心只能每8个周期分发一条指令,因为共享结构最多只能每两个周期处理一条指令。

关于每个子核心中内存队列的大小,我们估计它的大小为四,并且还有一个额外的锁存器存储下一条要派发到队列的指令,因此每个子核心可以缓冲五条连续的指令。指令在派发后预留一个队列条目,并在离开该单元时释放它。

我们还测量了每种指令类型的两种延迟,结果呈现在表2中。第一种延迟是加载指令分发后,到消费者或覆盖相同目标寄存器的指令可以分发的最早时间。我们称之为RAW/WAW延迟(注意存储指令不能产生寄存器RAW/WAW依赖)。第二种是加载或存储指令分发后,到写入加载/存储指令源寄存器的指令可以分发的最早时间。我们称之为WAR延迟

我们得出结论,如果指令使用统一寄存器(uniform registers)来计算地址,那么全局内存访问会更快,而不是常规寄存器,因为地址计算更快(9 vs 11个周期)。当使用统一寄存器时,一个warp中的所有线程共享同一个寄存器,因此只需要计算一个内存地址。另一方面,当使用常规寄存器时,每个线程需要计算一个可能不同的内存地址。

我们还观察到,共享内存加载的延迟低于全局内存(23 vs 29个周期)。然而,它们对于常规寄存器和统一寄存器的WAR延迟是相同的(9个周期),而它们的RAW/WAW延迟对于统一寄存器则低一个周期(23和24个周期)。WAR延迟对于常规和统一寄存器相等的事实表明,共享内存的地址计算是在SM的共享结构中完成的,而不是在本地子核心结构中,因此WAR依赖在源寄存器被读取后就解除了。


表2:内存指令延迟(单位:周期)。带*的值是近似值,因为我们无法收集到这些数据。

延迟还取决于读/写值的大小。对于WAR依赖,地址在统一寄存器中的全局加载延迟始终为9个周期,因为源操作数仅用于地址计算,因此无论加载值的大小如何,它们的大小总是相同的。对于存储指令,WAR延迟随着写入内存的值的大小而增加(与32位操作相比,64位操作增加2个周期,128位操作增加6个周期),因为该值需要从寄存器文件中读取。对于RAW/WAW依赖(仅适用于加载),延迟随着读取值的大小增加而增加,因为需要从内存传输更多数据到寄存器文件。我们测量到该传输的带宽为每周期512位。例如,一个全局内存加载指令,每个线程访问32位并使用统一寄存器作为地址,需要29个周期,而加载64位的相同指令需要31个周期,加载128位则需要35个周期。

常量缓存的WAR延迟(29个周期)显著大于全局内存加载的延迟,而RAW/WAW延迟(29个周期)则稍低。我们无法确认任何解释这一观察的假设。然而,我们发现由固定延迟指令完成的对常量内存的访问会进入一个与加载常量指令不同的缓存级别。我们通过一个LDC指令将一个给定地址预加载到常量缓存中并等待其完成来证实这一点。然后,我们分发一条使用相同常量内存地址的固定延迟指令,并测量到分发被延迟了79个周期,这对应于一次未命中,而不是没有延迟,那将对应于一次命中。这意味着访问常量地址空间的固定延迟指令使用L0 FL(固定延迟)常量缓存,而LDC指令使用一个不同的缓存,我们称之为L0 VL(可变延迟)常量缓存(见图3)。

最后,我们分析了LDGSTS指令,该指令旨在减少寄存器文件的压力并提高向GPU传输数据的效率【40】。它从全局内存加载数据并直接将其存储到共享内存,无需经过寄存器文件,从而节省了指令和寄存器。其延迟与指令的粒度无关。WAR依赖对所有粒度的延迟都相同(13个周期),因为它们在地址计算完成时就被解除了。RAW/WAW依赖在指令的读取步骤完成时被解除(39个周期),与指令的粒度无关。

A7 补充细节

我们从零开始设计了Accel-sim框架模拟器【53】的SM/核心模型,以实现第4节、第5节中解释并如图3所示的所有细节。主要的新组件概述如下。

我们为每个子核心增加了一个带有流缓冲区预取器的L0指令缓存。L0指令和常量缓存通过参数化延迟连接到一个L1指令/常量缓存。我们根据经验测量以及Jia等人【46, 48】描述的Ampere和Turing GPU的架构细节,配置了缓存层次结构,包括大小和延迟。

我们修改了分发阶段,以支持控制位、对新增加的L0常量缓存进行固定延迟指令的标签查找,以及新的CGGTY分发调度器。我们加入了控制(Control)阶段,在此阶段指令增加依赖计数器;以及分配(Allocate)阶段,在此阶段固定延迟指令检查访问寄存器文件和寄存器文件缓存的冲突。

关于内存指令,我们为每个子核心建模了一个新单元,并为子核心之间共享的单元建模,其延迟如前一节所述。此外,我们实现了一个待处理请求表(Pending Request Table, PRT),如Nyland等人【79】和Lashgar等人【54】所描述,以精确模拟与warp内内存访问合并相关的时间行为。

对于张量核心指令,我们实现了一个可变延迟模型,该模型取决于操作数的类型和大小,正如Abdelkhalik等人【3】所展示的。

此外,在没有专用双精度执行单元的架构中,我们为所有子核心共享的执行流水线建模了双精度指令。而且,我们精确地建模了读/写使用多个寄存器的操作数的时间,这在以前被近似为每个操作数只使用一个寄存器。此外,我们修复了先前工作【43】中报告的指令地址的一些不准确之处。

模拟最新的NVIDIA架构Blackwell带来了一些挑战。我们增强了模拟器以支持更新的SASS指令,如DPX【58】。此外,Blackwell【76】中的L2缓存大小相对于Ampere【73】显著增加,增加了十倍以上。因此,我们扩展了IPOLY哈希函数以适应Blackwell中这些更大的L2缓存。

除了在模拟器中实现新的SM/核心模型外,我们还扩展了跟踪器工具。该工具已扩展为转储所有类型操作数(常规寄存器、统一寄存器、谓词寄存器、立即数操作数等)的ID。另一个重要的扩展是能够获取所有指令的控制位,因为NVBit【94】不提供对它们的访问。这是通过在编译时使用CUDA二进制实用程序【77】获取SASS来实现的。这意味着编译应用程序以生成依赖于微架构的代码,而不是使用即时编译方法。不幸的是,对于少数几个内核(全部属于Deepbench),NVIDIA工具不提供SASS代码,这妨碍了获取这些指令的控制位。为了模拟这些应用程序,我们对依赖关系使用一种混合模式,其中在没有SASS代码的内核中使用传统的记分板;否则,使用控制位。

我们对该工具进行的另一项扩展是捕获对常量缓存的内存访问地址。

这个基于Accel-sim的增强型跟踪器和模拟器的代码可在[42]获取。

A4 实验环境


表3:基准测试套件。

A4 实验结果

性能准确性

表4展示了两种模型(本文模型和Accel-sim)相对于真实硬件在每款GPU上的平均绝对百分比误差(MAPE)。可以看出,本文模型在所有评估的GPU上都显著比Accel-sim更准确。例如,对于NVIDIA RTX A6000,本文模型的MAPE不到Accel-sim的一半。两种模型与真实硬件的相关性都相当高,本文模型略高。


表4:GPU规格和性能准确性。

图5展示了两种模型在NVIDIA RTX A6000上对128个基准测试的绝对百分比误差(APE),按误差从小到大排序。可以看出,本文模型在所有应用上的APE都始终低于Accel-sim,并且对于半数的应用,差异相当显著。此外,Accel-sim对6个应用的APE大于或等于100%,最坏情况下达到513%,而本文模型的APE从未超过62%。从第90百分位的尾部准确性来看,Accel-sim的APE为89.31%,而本文模型为29.78%。这证明了本文模型比Accel-sim模型显著更准确和鲁棒。


图5:NVIDIA RTX A6000模型对每个基准测试的绝对百分比误差,按升序排列。

结果在其他架构上也是一致的。例如,对于Turing架构的NVIDIA RTX 2080 Ti GPU,本文模型相比Accel-sim将MAPE降低了10.08%,并提高了相关性。对于Blackwell架构,本文模型实现了17.41%的MAPE和0.99的相关性,证明了我们的方法对NVIDIA最新架构的适用性。值得注意的是,没有Accel-sim对Blackwell的结果,因为据我们所知,我们的模型是GPGPU模拟领域第一个支持该架构的模型。总体而言,这些结果表明我们的发现至少从Turing架构到Blackwell架构都适用,并可能对未来的NVIDIA GPU世代保持相关性。

指令预取敏感性分析

流缓冲区指令预取器的特性对整体模型的准确性有很大影响。本节分析了不同配置的准确性,包括禁用预取器、使用完美指令缓存,以及大小为1、2、4、8、16和32个条目的流缓冲区预取器。我们仅展示NVIDIA RTX A6000的结果,但结论对其他评估的GPU基本相同。每种配置的MAPE如表5所示。


表5:NVIDIA RTX A6000上不同预取器配置的MAPE,以及相对于禁用预取器的加速比。

可以看出,最佳准确性是通过大小为8的流缓冲区获得的。我们还可以观察到,在GPU中,这种简单的预取器表现接近于完美的指令缓存。这是因为每个子核心中的不同warp通常执行相同的代码区域,并且典型的GPGPU应用程序的代码没有复杂的控制流,因此预取后续的若干行通常表现得非常好。

寄存器文件架构敏感性分析

表6说明了寄存器文件缓存和增加每个存储体(bank)的寄存器文件读取端口数量如何影响模拟的准确性和性能。它还显示了所有操作数都能在一个周期内检索的理想(Ideal)场景的结果。


表6:NVIDIA RTX A6000上不同RF配置的APE,相对于基线(1个读端口和RFC开启)的加速比,以及至少有一个操作数使用复用位的静态指令百分比。

所有基准测试的平均性能和准确性在所有配置中都相似。然而,对特定基准测试(如计算密集型的MaxFlops和Cutlass-sgemm)的仔细研究揭示了更细微的行为。

总之,寄存器文件架构及其缓存对个别基准测试有重要影响,因此精确建模至关重要。

替代依赖管理机制分析

本小节分析了本文所解释的软硬件协同依赖处理机制在性能和面积上的影响,并将其与早期GPU使用的传统记分板方法进行比较。表7显示了两种方法的结果。面积开销是相对于一个SM的常规寄存器文件面积(256 KB)报告的。


表7:不同依赖管理机制的加速比、面积开销和MAPE。

基于传统记分板的机制准确性稍差,性能略低,但面积开销大得多。
- 面积:控制位机制每个SM仅需1968位,占寄存器文件大小的0.09%。而一个支持63个消费者的记分板需要111,552位,占寄存器文件大小的5.32%。
- 性能:传统记分板机制的性能强烈依赖于它能支持的最大消费者数量。虽然支持1个和63个消费者的平均性能差异仅为0.03x,但对特定基准测试(如Cutlass-sgemm)影响巨大。当只能跟踪一个消费者时,Cutlass-sgemm经历了0.62x的显著减速。
- 结论:本文提出的软硬件协同机制在性能和面积开销方面均优于传统的记分板方法。

A5 结论

本文通过对真实硬件进行逆向工程,揭示了现代NVIDIA GPU核心的微架构。我们描述了用于处理数据依赖的硬件-编译器协同方法,以及寄存器文件缓存。我们剖析了分发阶段的逻辑,发现分发调度器遵循CGGTY(编译器指导的贪婪后最年轻)策略。我们揭示了寄存器文件的关键细节,如端口数量及其宽度,并揭示了寄存器文件缓存的工作原理。我们还发现了一些内存流水线的重要特性,包括加载/存储队列的大小、子核心之间的争用、不同内存数据粒度的延迟,以及指令预取器机制的细节。

此外,我们将所有这些特性在一个先进的模拟器中建模,并将这个新模型与真实硬件进行比较,证明了它通过将其准确性提高了超过20.58%,从而更接近现实。此外,我们证明了一个简单的流缓冲区用于指令预取在模拟准确性方面表现良好,其性能接近于一个完美的指令缓存。我们还展示了现代NVIDIA GPU中使用的基于控制位的依赖管理机制优于其他替代方案,如传统的记分板。最后,我们研究了寄存器文件缓存和寄存器文件读取端口数量如何影响模拟的准确性和性能。