19.7 多处理器系统
前面已经详细讨论了处理器的设计和实现,以及优化其性能的几种方法,如管线。通过优化处理器和内存系统,可以显著提高程序的性能。问题是,这足够了吗?有没可能做得更好?
简短答案:也许不是。从处理器性能有其局限性开始说起。不可能单独提高处理器的速度,即使是非常复杂的超标量处理器和高度优化的内存系统,通常不可能将IPC增加超过50%。其次,由于功率和温度的考虑,很难将处理器频率提高到3 GHz以上。在过去相当多年中,处理器频率基本保持不变,由此CPU性能的增长也非常缓慢。
下面两图中证明了以上论述。下图显示了英特尔、AMD、Sun、高通和富士通等多家供应商从2001年到2010年发布的处理器的峰值频率。我们观察到,频率或多或少保持不变(大多在1 GHz到2.5 GHz之间),这些趋势表明频率没有逐渐增加。预计在不久的将来,处理器的频率也将限制在3 GHz。
CPU频率。
下图显示了2001年至2010年同一组处理器的Spec Int 2006平均得分。我们观察到,随着时间的推移,CPU性能逐渐饱和,提高性能变得越来越困难。
CPU性能。
尽管单个处理器的性能预计在未来不会显著提高,但计算机架构的未来并不黯淡,因为处理器制造技术正在稳步进步,导致更小更快的晶体管。直到20世纪90年代末,处理器设计者一直在利用晶体管技术的进步,通过实现更多功能来增加处理器的复杂性。然而,由于复杂度和功耗的限制,2005年后,设计师们转而使用更简单的处理器。供应商没有在处理器中实现更多功能,而是决定在单个芯片上安装多个处理器,有助于同时运行多个程序。或者,可以将单个程序拆分为多个部分,并行运行所有部分。
这种使用多个并行运行的计算单元的范例称为多处理(multiprocessing)。多处理是一个相当通用的术语,可以指同一芯片中的多个处理器并行工作,也可以指跨芯片的多个并行处理器。多处理器是一种支持多处理的硬件,当我们在一个芯片中有多个处理器时,每个处理器都被称为一个核心,而这个芯片被称为多核(multicore)处理器。
我们正处于多处理器(multiprocessors)时代,尤其是多核(multicore)系统。每个芯片的核数大约每两年增加两倍,正在编写新的应用程序来利用这些额外的硬件。大多数专家认为计算的未来在于多处理器系统。
在开始设计不同类型的多处理器之前,让我们先来看看多处理器的背景和历史。
19.7.1 多处理器背景
在60年代和70年代,大型计算机主要被银行和金融机构使用。他们拥有越来越多的消费者,因此需要能够每秒执行越来越多事务的计算机。通常,只有一个处理器被证明不足以提供所需的计算吞吐量。因此,早期的计算机设计师决定在一台计算机中安装多个处理器。处理器可以共享计算负载,从而增加整个系统的计算吞吐量。
最早的多处理器之一是Burroughs 5000,它有两个处理器:A和B。A是主处理器,B是辅助处理器。当负载很高时,处理器A给处理器B一些工作要做。当时几乎所有其他主要供应商都有多处理器产品,如IBM 370、PDP 11/74、VAX-11/782和Univac 1108-II,这些计算机支持第二个CPU芯片,已连接到主处理器。在所有这些早期机器中,第二个CPU位于第二个芯片上,该芯片通过导线或电缆与第一个CPU物理连接。它们有两种类型:对称和不对称。对称多处理器由多个处理器组成,每个处理器都是相同类型的,并且可以访问操作系统和外围设备提供的服务。非对称多处理器为不同的处理器分配不同的角色,通常有一个独特的处理器来控制操作系统和外围设备,其余的处理器都是从机,它们从主处理器获取工作,并返回结果。
对称多处理器(Symmetric Multiprocessing):此范例将多处理器系统中的所有组成处理器视为相同的,每个处理器都可以平等地访问操作系统和I/O外围设备,也称为SMP系统。
非对称多处理器(Asymmetric Multiprocessing):此范例并不将多处理器系统中的所有组成处理器视为相同的,通常有一个主处理器独占控制操作系统和I/O设备,将工作分配给其他处理器。
早期,第二个处理器使用一组电缆连接到主处理器,通常位于主计算机的不同区域。请注意,在那个年代,电脑曾经有一个房间那么大。随着小型化程度的提高,两个处理器逐渐接近。在80年代末和90年代初,公司开始在同一主板上安装多个处理器。主板是一块印刷电路板,包含计算机使用的所有芯片,带有芯片和金属线的大型绿色电路板是主板。到了90年代末,在一块主板上可以有四到八个处理器,它们通过专用高速总线相互连接。
渐渐地,多核处理器的时代开始了,同一芯片中有多个处理器。2001年,IBM率先推出了名为Power 4的双核(2核)多核处理器,2005年,英特尔和AMD也推出了类似产品。截至2022年,有16、32、64甚至更多核心的多核处理器可供选择。
现在更深入地了解一下1960年至2012年间处理器世界发生了什么。在六十年代,一台电脑通常只有一个房间那么大,而今,口袋里装着一台电脑。在60年代早期,手机中的处理器比IBM 360机器快约160万倍,它的功率效率也提高了几个数量级。计算机技术持续发展的主要驱动因素是晶体管的小型化,晶体管在六十年代曾经有几毫米的沟道长度,现在大约有20-30纳米长。1971年,一个典型的芯片曾经有2000-3000个晶体管,如今的一个芯片有数十亿个晶体管。
在过去的四十到五十年中,每个芯片的晶体管数量大约每1-2年翻一番。事实上,英特尔的联合创始人戈登·摩尔(Gordon Moore)在1965年就预测到了这一趋势。摩尔定律预测,芯片上的晶体管数量预计每一到两年就会翻一番。最初,摩尔曾预测每年翻倍的时间,随着时间的推移,这段时间已经变成了大约2年。由于制造技术、新材料和制造技术的稳步发展,这种情况预计会发生。
摩尔定律自20世纪60年代中期提出以来,几乎一直成立。如今,几乎每两年,晶体管的尺寸就会缩小√22倍,确保了晶体管的面积缩小一倍,从而可以使芯片上的晶体管数量增加一倍。让我们将特征尺寸(feature size)定义为可以在芯片上制造的最小结构的尺寸。下表显示了过去10年英特尔处理器的功能大小,我们观察到特征大小每两年大约减少√22(1.41)倍,导致晶体管数量加倍。
年份 | 特征尺寸 |
---|---|
2001 | 130 nm |
2003 | 90 nm |
2005 | 65 nm |
2007 | 45 nm |
2009 | 32 nm |
2011 | 22 nm |
请注意,摩尔定律是一个经验定律。然而,由于它在过去四十年中正确预测了趋势,因此在技术文献中被广泛引用。它直接预测了晶体管尺寸的小型化,更小的晶体管更省电、更快。传统上,设计师们利用这些优势来设计具有额外晶体管的更大处理器,他们使用额外的晶体管预算来增加不同单元的复杂性,增加缓存大小,增加问题宽度和功能单元的数量。其次,管线阶段的数量也在稳步增加,直到2002年左右,时钟频率也随之增加。然而,2002年之后,计算机架构的世界发生了根本性的变化。突然间,电力和温度成了主要问题。处理器功耗曲线开始超过100瓦,芯片温度开始超过100摄氏度,这些限制显著地结束了处理器复杂性和时钟频率的扩展。
相反,设计师开始在不改变其基本设计的情况下,为每个芯片封装更多的内核,确保了每个核的晶体管数量保持不变。根据摩尔定律,核的数量每两年翻一番,开启了多核处理器的时代,处理器供应商开始将芯片上的核数量增加一倍。在未来不久,每个芯片的核数预计普遍达到64、128个甚至更多。
除了常规的多核处理器,还有另一个重要的发展。除了每个芯片有4个大内核,还有一些架构在芯片上有64-256个非常小的内核,例如图形处理器。这些处理器也遵循摩尔定律,每2年将其内核翻倍,被越来越多地用于计算机图形学、数值计算和科学计算。也可以拆分处理器的资源,使其支持两个程序计数器,并同时运行两个程序,这些特殊类型的处理器被称为多线程处理器。
本章让读者了解多处理器设计的广泛趋势,首先从软件的角度来看多处理,一旦确定了软件需求,将着手设计支持多处理的硬件,将广泛考虑多核、多线程和矢量处理器。
19.7.2 多处理器系统软件
19.7.2.1 强和松散耦合多处理
松散耦合多处理(Loosely Coupled Multiprocessing)是在多处理器上并行运行多个不相关的程序。
强耦合多处理(Strongly Coupled Multiprocessing)是在多处理器上并行运行一组共享内存空间、数据、代码、文件和网络连接的程序。
本文将主要研究强耦合多处理,并主要关注通过共享大量数据和代码来允许一组程序协同运行的系统。
19.7.2.2 共享内存与消息传递
计算机架构师按照不同的模式为多处理器设计了一套协议。第一个范例被称为共享内存,所有单独的程序都看到内存系统的相同视图,如果程序A将x的值更改为5,则程序B立即看到更改。第二种设置称为消息传递,多个程序通过传递消息相互通信。共享内存范例更适合强耦合多处理器,消息传递范例更适合松散耦合多处理器。请注意,可以在强耦合多处理器上实现消息传递。同样,也可以在松散耦合的多处理器上实现共享内存的抽象,被称为分布式共享内存(distributed shared memory),但通常不是常态。
共享内存
让我们尝试使用多处理器并行添加n个数字,它的代码如下所示,使用OpenMP语言扩展用C++编写了代码。假设所有的数字都已经存储在一个称为numbers.的数组中,数组编号有SIZE个条目,假设可以启动的并行子程序的数量等于N。
/* 变量声明 */
int partialSums[N];
int numbers[SIZE];
int result = 0;
/* 初始化数组 */
(...)
/* 并行代码 */
#pragma omp parallel
{
/* get my processor id */
int myId = omp_get_thread_num();
/* add my portion of numbers */
int startIdx = myId * SIZE/N;
int endIdx = startIdx + SIZE/N;
for(int jdx = startIdx; jdx < endIdx; jdx++)
partialSums[myId] += numbers[jdx];
}
/* 顺序代码 */
for(int idx=0; idx < N; idx++)
result += partialSums[idx];
除了指令#pragma omp parallel
之外,很容易将代码误认为是常规顺序程序,这是在并行程序中添加的唯一额外语义差异,它将此循环的每个迭代作为单独的子程序启动,每个这样的子程序都被称为线程。线程通过修改共享内存空间中内存位置的值与它们通信,每个线程都有自己的一组局部变量,其他线程无法访问这些变量。
迭代次数或启动的并行线程数是预先设置的系统参数,通常等于处理器的数量,上述代码中等于N。因此,并行启动代码的并行部分的N个副本,每个副本在单独的处理器上运行。请注意,程序的每个副本都可以访问在调用并行部分之前声明的所有变量,例如,可以访问partialSums
和numbers
数组。每个处理器都调用函数omp_get_thread_num
,该函数返回线程的id。每个线程都使用线程id来查找需要添加的数组范围,在数组的相关部分中添加所有条目,并将结果保存在partialSums
数组中相应的条目中。一旦所有线程都完成了它们的工作,顺序部分就开始了,这段顺序代码可以在任何处理器上运行,是由操作系统或并行编程框架在运行时动态做出的。为了得到最终结果,必须将顺序部分中的所有部分和相加。
计算的图形表示如下图所示。父线程生成一组子线程,做各自的工作,完成后最终连接,父线程接管并聚合并行结果。此例也是Fork-Join范例的一个具体示例。
并行加法程序的图形表示。
有几个要点需要注意。每个线程都有自己的堆栈,可以使用其堆栈声明其局部变量。一旦完成,堆栈中的所有局部变量都将被销毁。要在父线程和子线程之间传递数据,必须使用两个线程都可以访问的变量,所有线程都需要全局访问这些变量,子线程可以自由地修改这些变量,甚至可以使用它们相互通信。此外,它们还可以自由调用操作系统,并写入外部文件和网络设备。一旦所有线程完成执行,它们就执行一个联接操作,并释放它们的状态,父线程接管并完成聚合结果的角色。join是线程之间同步操作的一个示例,线程之间可以有许多其他类型的同步操作。有一组复杂的结构,线程可以用来协同执行非常复杂的任务,添加一组数字是一个非常简单的例子。多线程程序可以用于执行其他复杂任务,如矩阵代数,甚至可以并行求解微分方程。
消息传统
接下来简单地看看消息传递,只给读者一个消息传递程序的概况,在这种情况下,每个程序都是一个单独的实体,不与其他程序共享代码或数据。它是一个进程,其中进程被定义为程序的运行实例,通常不与任何其他进程共享其地址空间。
现在快速定义消息传递语义,主要使用两个函数:send和receive,如下表所示。send(pid, val)函数用于向id等于pid的进程发送整数(val),receive(pid)用于接收id等于pid的进程发送的整数。如果pid等于ANYSOURCE,那么接收函数可以返回任何进程发送的值。我们的语义基于流行的并行编程框架MPI(消息传递接口),MPI调用有更多的参数,语法相对复杂。
函数 | 语意 |
---|---|
send(pid, val) | 将整数val发送给id等于pid的进程。 |
receive(pid) | 1、 从进程pid接收整数。 2、 函数会一直阻塞,直到它得到值。 3、 如果pid等于ANYSOURCE,则接收函数返回任何进程发送的值。 |
现在考虑以下示例并行添加n个数字的相同示例。假设所有的数字都存储在numbers数组中,并且这个数组可用于所有N个处理器,numbers元素数为SIZE。为了简单起见,假设SIZE可被N整除。
/* start all the parallel processes */
SpawnAllParallelProcesses();
/* For each process execute the following code */
int myId = getMyProcessId();
/* 计算部分和 */
int startIdx = myId * SIZE/N;
int endIdx = startIdx + SIZE/N;
int partialSum = 0;
for(int jdx = startIdx; jdx < endIdx; jdx++)
partialSum += numbers[jdx];
/* 所有非根节点将其部分和发送到根 */
if(myId != 0)
{
send (0, partialSum);
}
else
{
/* 处理根节点 */
int sum = partialSum;
for (int pid = 1; pid < N; pid++)
{
sum += receive(ANYSOURCE);
}
/* 关闭所有进程 */
shutDownAllProcesses();
return sum;
}
19.7.3 多处理器的设计空间
迈克尔·弗林(Michael J.Flynn)在1966年提出了著名的弗林对多处理器的分类,他从观察到不同处理器的集成可能共享代码、数据或两者兼而有之开始。有四种可能的选择:SISD(单指令单数据)、SIMD(单指令多数据)、MISD(多指令单数据)和MIMD(多指令多数据),下面描述这些类型的多处理器:
-
SISD:是一个标准的单处理器,具有单个流水线。SISD处理器可以被看作是一组只有单个处理器的多处理器的特例。
-
SIMD:SIMD处理器可以在一条指令中处理多个数据流,例如SIMD指令可以用一条指令将4组数字相加。现代处理器将SIMD指令纳入其指令集,并具有特殊的SIMD执行单元,例如包含SIMD指令集的SSE集的x86处理器。图形处理器和矢量处理器是高度成功的SIMD处理器的特殊例子。
多线程SIMD处理器数据路径的简化框图。。
-
MISD:MISD系统在实践中非常罕见,主要用于可靠性要求非常高的系统中。例如,大型商用飞机通常有多个处理器运行同一程序的不同版本,最终结果由表决(voting)决定。例如,一架飞机可能有一个MIPS处理器、一个ARM处理器和一个x86处理器,每个处理器都运行着相同程序的不同版本,如自动驾驶系统,它们有多个指令流,但只有一个数据源。专用投票电路(dedicated voting circuit)计算三个输出的多数投票。例如,由于程序或处理器中的错误,其中一个系统可能错误地决定左转,而其他两个系统都可能做出正确的右转决定,在这种情况下,投票电路将决定右转。由于MISD系统几乎从未在实践中使用过,除了特殊的例子,本文不再讨论它们。
-
MIMD:MIMD系统是目前最流行的多处理器系统,它们有多个指令流和多个数据流,多核处理器和大型服务器都是MIMD系统。多个指令流意味着指令来自多个来源,每个源都有其唯一的位置和相关的程序计数器。MIMD范式的两个重要分支在过去几年中形成。
第一个是SPMD(单程序多数据),第二个是MPMD(多程序多数据),大多数并行程序以SPMD风格编写。同一程序的多个副本在不同的内核或独立的处理器上运行,然而,每个单独的处理单元都有单独的程序计数器,因此可以感知不同的指令流。有时,SPMD程序的编写方式会根据线程ID执行不同的操作,SPMD的优点是我们不必为不同的处理器编写不同的程序。同一程序的部分可以在所有处理器上运行,尽管它们的行为可能不同。
一个对比的范例是MPMD,在不同处理器上运行的程序实际上是不同的,它们对于具有异构处理单元的专用处理器更有用。通常只有一个主程序将工作分配给从程序,从属程序完成分配给它们的工作量,然后将结果返回给主程序。这两个程序的工作性质实际上非常不同,通常不可能将它们无缝地组合到一个程序中。
在MIMD组织中,处理器是通用的,每个处理器都能够处理执行适当数据转换所需的所有指令。MIMD可以通过处理器通信的方式进一步细分(下图)。
如果处理器共享一个公共存储器,则每个处理器访问存储在共享内存中的程序和数据,处理器通过该内存相互通信,这种系统最常见的形式是对称多处理器(SMP)。在SMP中,多个处理器通过共享总线或其他互连机制共享单个内存或内存池,区别特征在于,对于每个处理器,对任何内存区域的内存访问时间大致相同。前些年的一个发展是非均匀内存访问(NUMA)组织,如下图所述。顾名思义,NUMA处理器对不同内存区域的内存访问时间可能不同。
从上面的描述可以清楚地看出,我们需要关注的系统是SIMD和MIMD。由于MISD系统很少使用,不再讨论,下面首先讨论MIMD多处理,注意只描述MIMD多处理的SPMD变体,因为SPMD是最常见的方法。
19.7.4 MIMD多处理器
现在让我们更深入地研究基于强耦合共享内存的MIMD机器,首先从软件的角度来看它们,从软件的角度制定了这些机器的广泛规格之后,可以继续对硬件的设计进行简要概述。请注意,并行MIMD机器的设计可能需要一整本书来描述。
将共享内存MIMD机器的软件接口称为逻辑角度(logical point of view),并将多处理器的实际物理设计称为物理角度(physical point of view)。当描述逻辑角度时,主要关心的是多处理器相对于软件的行为,硬件对其行为有什么保证,软件可以期待什么,包括正确性、性能,甚至是故障恢复能力。物理角度与多处理器的实际设计有关,包括处理器、存储系统和互连网络的物理设计。请注意,物理角度必须符合逻辑角度。此处采用了与单处理器类似的方法,首先通过查看汇编代码来解释软件视图(架构),然后通过描述流水线处理器(组织)为汇编代码提供了一个实现。
19.7.4.1 逻辑视角
下图显示了共享内存MIMD多处理器的逻辑视图。每个处理器都连接到存储代码和数据的内存系统,其程序计数器指向它正在执行的指令的位置,即在内存的代码段,此段通常是只读的,因此不受我们有多处理器这一事实的影响。
多处理器系统的逻辑视图。
实现共享内存多处理器的主要挑战是正确处理数据访问。上图显示了一种方案,其中每个计算处理器都连接到内存,并将其视为一个黑盒。如果考虑具有不同虚拟地址空间的进程系统,就没有问题。每个处理器都可以处理其数据的私有副本,由于内存占用实际上是不相交的,可以很容易地在这个系统中运行一组并行进程。然而,当研究具有多个线程的共享内存程序,并且存在跨线程的数据共享时,主要的复杂性就出现了。请注意,我们还可以通过将不同的虚拟页面映射到同一物理帧来跨进程共享内存,把这种情况视为并行多线程软件的一种特殊情况。
一组并行线程通常共享其虚拟和物理地址空间,但线程也有私有数据,这些数据保存在它们的堆栈中。有两种方法可以实现不相交的堆栈。第一,所有线程都可以有相同的虚拟地址空间,不同的堆栈指针可以从虚拟地址空间中的不同点开始,需要进一步确保线程堆栈的大小不足以与另一个线程的堆栈重叠。另一种方法是将不同线程的虚拟地址空间的堆栈部分映射到不同的内存帧,每个线程可以在其页面表中为堆栈部分有不同的条目,但对于虚拟地址空间的其余部分(如代码、只读数据、常量和堆变量)有共同的条目。
在任何情况下,并行软件复杂性的主要问题都不是因为代码是只读的,也不是因为线程之间不共享的局部变量,主要问题是由于数据值可能在多个线程之间共享。这就是并行程序的强大之处,也使它们变得非常复杂。在前面展示的并行添加一组数字的示例中,我们可以清楚地看到通过共享内存共享值和计算结果所获得的优势。
然而,跨线程共享值并不是那么简单,是一个相当深刻的话题,本文简要地看一下其中的两个重要主题,即连贯性(coherence)和内存一致性(memory consistency)。当在缓存上下文中提到一致性时,一致性也称为缓存一致性。然而,一致性不仅仅限于缓存,它是一个通用术语。
一致性
内存系统中的一致性是指多个线程访问同一位置的方式。当多个线程访问同一内存位置时,许多不同的行为都是可能的,有些行为直觉上是错误的,但也有可能。在研究一致性之前,需要注意,在内存系统中,有许多不同的实体,如缓存、写入缓冲区和不同类型的临时缓冲区。处理器通常将值写入临时缓冲区,然后恢复其操作。内存系统的工作是将数据从这些缓冲区传输到缓存子系统中的某个位置。因此,在内部,给定的内存地址可能在给定的时间点与许多不同的物理位置相关联。其次,将数据从处理器传输到存储器系统中的正确位置(通常是缓存块)的过程不是瞬时的,内存读取或写入请求有时需要超过几十个周期才能到达其位置。如果内存流量很大,这些内存请求消息可能会等待更长时间,消息也可以与之后发送的其他消息重新排序。
让我们假设内存对于所有处理器来说都像一个大的字节数组,尽管在内部,它是一个由不同组件组成的复杂网络,这些组件努力为读/写操作提供简单的逻辑抽象。多处理器内存系统的内部复杂性导致了访问同一组共享变量的程序的几种有趣行为。
让我们考虑一组示例。在每个示例中,所有共享值都被初始化为0,所有局部变量都以t开头,如t1、t2和t3。假设线程1写入跨线程共享的变量x,紧接着,线程2尝试读取其值。
// Thread 1:
x = 1
// Thread 2:
t1 = x
线程2是否保证读取1?或者,它可以得到以前的值0吗?如果线程2在2 ns甚至10 ns后读取x的值,该怎么办?一个线程中的写入传播到其他线程所需的时间是多少?这些问题的答案取决于内存系统的实现。如果内存系统有快速总线和快速缓存,那么写操作可以很快地传播到其他线程。但是,如果总线和缓存很慢,那么其他线程可能需要更多时间才能看到对共享变量的写入。
现在,把这个例子进一步复杂化,假设线程1写入x两次:
// Thread 1:
x = 1
x = 2
// Thread 2:
t1 = x
t2 = x
现在让我们看看一系列可能的结果:(t1,t2)=(1,2)、(t1,t2) = (0,1)都是可能的,当t1在线程1启动之前写入,而t2在线程1的rst语句完成之后写入时,这是可能的。同样,可以系统地列举所有可能结果的集合,这些结果是:(0,0)、(0,1)、(0,2)、(1,1)、(1,2)和(2,2)。有趣的问题是,结果(2,1)是否可能?如果对x的第一次写入在内存系统中被延迟,而第二次写入超过了它,这也许是可能的,但问题是我们是否应该允许这种行为。
答案是否定的。如果我们允许这种行为,那么实现多处理器内存系统无疑会变得更简单,但编写和推理并行程序将变得非常困难。因此,大多数多处理器系统都不允许这种行为。
现在稍微正式地看看多个线程访问同一内存位置的问题。我们理想地希望内存系统是连贯的,意味着在处理对同一内存地址的不同访问时,它应该遵守一组规则,以便更容易编写程序。
存储器访问同一内存地址的行为称为一致性(coherence)。
通常,一致性有两个公理:
- 完成(Completion):写入必须最终完成。此公理表示,内存系统中永远不会丢失任何写入,例如不可能将值10写入变量x,而写入请求会被内存系统丢弃,它需要到达x对应的内存位置,然后需要更新其值,稍后可能会被另一个写入请求覆盖。然而,底线是写请求需要在将来的某个时间点更新内存位置。
- 顺序(Order):对同一内存地址的所有写入都需要被所有线程以相同的顺序看到。此公理表示,所有线程都认为对内存位置的所有写入顺序相同,意味着不可能读取上面案例中的(2,1),因为线程1知道2是在1之后写入到存储位置x的,根据顺序公理,所有其他线程都需要感知到写入x的相同顺序,它们对x的感知不能与线程1的感知不同,因此,它们不能在1之后读取2。一致性的公理具有直观的意义,基本上意味着所有的写入最终都会完成,单处理器系统也是如此。其次,所有处理器都看到单个内存位置的相同视图,如果其值从0变为1变为2,则所有处理器都会看到相同的变化顺序(0-1-2),没有处理器以不同的顺序看到更新。这进一步意味着,无论内存系统如何在内部实现,在外部,每个内存位置都被视为可全局访问的单个位置。
内存一致性
一致性是指对同一内存位置的访问,如何访问不同的存储位置?可用一系列例子来解释。
// Thread 1:
x = 1;
y = 1;
// Thread 2:
t1 = y;
t2 = x;
现在从直观的角度来看t1和t2的允许值,总是可以获得(t1,t2)=(0,0),当线程2在线程1之前调度时,可能会发生这种情况。还可能获得(t1,t2)=(1,1),当线程2在线程1完成后调度时,会发生这种情况。同样,可以读取(t1,t2)=(0,1)。下图显示了如何获得所有三种结果。
所有可能结果的示意图。
有趣的问题是(t1,t2)=(1,0)是否被允许?当对x的写入被内存系统以某种方式延迟,而对y的写入很快完成时,就会发生这种情况。在这种情况下,t1将获得y的更新值,t2将获得x的旧值。是否允许这种行为?很明显,如果允许这种行为,就很难对软件和并行算法的正确性进行推理,编程也将变得困难。然而,如果允许这种行为,那么硬件设计就会变得更简单,因为不必为软件提供强有力的保证。
答案显然没有对错之分?完全取决于我们想要如何编程软件,以及硬件设计师想要为软件编写人员构建什么。但是,这个例子仍然有一些非常深刻的东西,(t1,t2)=(1,0)的特例。为了找出原因,再次查看上图,我们已经能够通过在两个线程的指令之间创建交错来推理三个结果。在这些交错中,同一线程中的指令顺序与程序中指定的顺序相同,称为程序顺序(program order)。
与每个组成线程的控制流语义一致的指令顺序(可能属于多个线程)称为程序顺序(program order)。线程的控制流语义被定义为一组规则,用于确定在给定指令之后可以执行哪些指令,例如,单周期处理器执行的指令集总是按程序顺序执行。
很明显,我们不能通过按程序顺序交错线程来生成结果(t1,t2)=(1,0)。
如果我们能从可能的输出集合中排除输出(1,0),那就好了,将允许编写并行软件,很容易地预测可能的结果。确定并行程序可能结果集的内存系统模型称为内存模型(memory model)。
确定并行程序可能结果集的内存系统模型称为内存模型(memory model)。
顺序一致性(Sequential Consistency)
我们可以有不同类型的内存模型,对应于不同类型的处理器,最重要的内存模型之一是顺序一致性(Sequential Consistency,SC)。顺序一致性表示,只允许通过按程序顺序交错线程生成那些结果,意味着上图所示的所有结果都是允许的,因为它们是通过以所有可能的方式交错线程1和线程2生成的,而不会违反它们的程序顺序。然而,结果(t1,t2)=(1,0)是不允许的,因为它违反了程序顺序,在顺序一致的内存模型中是不允许的。请注意,一旦我们按照程序顺序交错多个线程,就等于说我们有一个处理器在一个周期中执行一个线程的指令,可能在下一个周期执行另一个其他线程的指令。因此,处理多个线程的单处理器产生SC执行。事实上,如果我们考虑模型的名称,“sequential”一词来源于这样一个概念,即执行等同于单处理器以某种顺序顺序执行所有线程的指令。
如果一组并行线程的执行结果等同于单个处理器以某种顺序执行来自所有线程的指令的结果,则内存模型是顺序一致的。或者,可以将序列一致性定义为一个内存模型,其一组可能的结果是可以通过按程序顺序交错一组线程来生成的结果。
序列一致性是一个非常重要的概念,在计算机体系结构和分布式系统领域得到了广泛的研究。它通过将并行系统上的执行等同于顺序系统上的运行,将并行系统简化为具有一个处理器的串行系统。需要注意的一点是,SC并不意味着一组并行程序的执行结果始终相同,取决于线程的交错方式以及线程到达的时间,但某些结果是不允许的。
弱一致性(Weak Consistency)
SC的实施是有代价的,使软件变得简单,但使硬件变得非常慢。为了支持SC,通常需要等待读取或写入完成,然后才能将下一次读取或写入发送到内存系统。当任何处理器的所有后续读取都将获得W已写入的值或稍后写入同一位置的值时,写入请求W完成。读取数据后,读取请求完成,而最初写入数据的写入请求完成。
这些要求/限制成为高性能系统的瓶颈,因此计算机架构社区已经转向违反SC的弱内存模型。弱内存模型将允许以下多线程代码段中的结果(t1,t2)=(1,0)。
// Thread 1:
x = 1
y = 1
// Thread 2:
t1 = y
t2 = x
弱一致性(weakly consistent ,WC)内存模型不符合SC,通常允许任意内存排序。
弱内存模型有不同的类型,一个通用的变体是弱一致性(WC)。现在尝试找出为什么WC允许(1,0)结果,假设线程1在核心1上运行,线程2在核心2上运行。此外,假设对应于x的内存位置在核心2附近,对应于y的内存位置位于核心1附近。还假设从核心1附近向核心2发送请求需要数十个周期,并且延迟是可变的。
首先研究核心1的流水线的行为。从核心1流水线的角度来看,一旦将内存写入请求移交给内存系统,则认为内存写入指令已完成,指令进入RW阶段。因此,在这种情况下,处理器将在第-n个周期中将对x的写入移交给内存系统,然后在第(n+1)个周期中将写入传递给y。对y的写入将很快到达y的内存位置,而对x的写入将需要很长时间。
同时,核心2将尝试读取y的值。假设读取请求在写入请求(到y)到达y之后到达y的内存位置,将得到y的新值,该值等于1。随后,核心2将对x发出读操作,对x的读操作可能在对x的写操作到达x之前到达x的内存位置。在这种情况下,它将获取x的旧值,即0。因此,结果(1,0)在弱内存模型中是可能的。
为了避免这种情况,我们可以等待对x的写入完全完成,然后再向y发出写入请求,这样做虽然是正确的,但是一般来说,当我们写入共享内存位置时,其他线程不会在完全相同的时间点读取它们。我们无法在运行时区分这两种情况,因为处理器之间不共享它们的内存访问模式。为了提高性能,将每个内存请求延迟到前一个内存请求完成是不值得的。因此,高性能实现更喜欢允许来自同一线程的内存访问由内存系统重新排序的内存模型。我们将在后续小节中研究避免(1,0)结果的方法。
大多数处理器都假定内存请求在离开管线后的某个时间点瞬间完成,此外,所有线程都假定内存请求在完全相同的时间点瞬间完成。内存请求的这个属性称为原子性(atomicity)。其次,需要注意,内存请求的完成顺序可能与它们的程序顺序不同。当完成顺序与每个线程的程序顺序相同时,内存模型遵循SC,如果完成顺序与程序顺序不同,则内存模型是WC的变体。
当内存请求在发出后的某个时间点被所有线程感知为瞬时执行时,称其为原子的(atomic)或观察原子性(observe atomicity)。
准确地说,对于每个内存请求,都有三个感兴趣的事件,即开始、结束和完成。让我们考虑一个写请求。当指令将请求发送到MA阶段的L1缓存时,请求开始。当指令移动到RW阶段时,请求完成。在现代处理器中,无法保证在内存请求完成时写入会到达目标内存位置,写入请求到达内存位置且写入对所有处理器可见的时间点称为完成时间。在简单的处理器中,完成请求的时间介于开始时间和结束时间之间。然而,在高性能处理器中,情况并非如此。此概念如下图所示。
读请求怎么样?大多数人会天真地认为读取的完成时间介于开始时间和结束时间之间,因为它需要返回内存位置的值。然而,这并不完全正确,因为读取可能会返回尚未完成的写入的值。在要求写入原子性(写入瞬间完成的错觉)的内存模型中,只有当相应的写入请求完成时,读取才完成。所有假定写原子性的内存一致性模型都是使用内存访问完成顺序的属性来定义的。
在弱内存模型中,不遵循同一线程中独立内存操作之间的顺序。例如,当我们写到x,然后写到y时,线程2发现它们的顺序相反。然而,属于同一线程的从属内存指令的操作顺序始终受到遵循。例如,如果将变量x的值设置为1,然后在同一线程中读取它,我们将得到1或稍后写入x的值,所有其他线程都会感知内存请求的顺序相同。在由同一线程进行的从属内存访问之间,绝不存在任何内存顺序冲突(参见下图)。
多线程程序中内存请求的实际完成时间。
现在说明使用不遵守任何顺序规则的弱内存模型的困难。假设一个顺序一致的系统,让我们编写并行加法程序。请注意,不使用OpenMP,因为OpenMP在幕后做了很多工作,以确保程序在内存模型较弱的机器上正确运行。让我们定义一个并行构造,它并行运行一个代码块,以及一个getThreadId()函数,它返回线程的标识符,线程id的范围是从0到N-1。并行加法函数的代码如下所示。假设在并行部分开始之前,所有数组都被初始化为0,在并行部分中,每个线程将其部分数字相加,并将结果写入数组中相应的条目partialSums。完成后,它将完成数组中的条目设置为1。
/* variable declaration */
int partialSums[N];
int finished[N];
int numbers[SIZE];
int result = 0;
int doneInit = 0;
/* initialise all the elements in partialSums and finished to 0 */
(...)
doneInit = 1;
/* parallel section */
parallel
{
/* wait till initialisation */
while (!doneInit()){};
/* compute the partial sum */
int myId = getThreadId();
int startIdx = myId * SIZE/N;
int endIdx = startIdx + SIZE/N;
for(int jdx = startIdx; jdx < endIdx; jdx++)
partialSums[myId] += numbers[jdx];
/* set an entry in the finished array */
finished[myId] = 1;
}
/* wait till all the threads are done */
do
{
flag = 1;
for (int i=0; i < N; i++)
{
if(finished[i] == 0)
{
flag = 0;
break;
}
}
} while (flag == 0);
/* compute the final result */
for(int idx=0; idx < N; idx++)
result += partialSums[idx];
现在阐述需要聚合结果的线程,它需要等待所有线程完成计算部分和的工作,通过等待完成的数组中的所有条目都等于1来实现这一点。一旦确定完成的数组的所有条目均等于1,它就继续将所有部分和相加,以获得最终结果。可以很容易验证,如果假设一个顺序一致的系统,那么这段代码会正确执行。她需要注意的是,只有当读取数组中的所有条目完成为1时,才计算结果。如果计算部分和并写入partialSums数组,则完成数组中的条目等于1。由于我们添加了partialSums数组的元素来计算最终结果,因此可以得出结论,它是正确计算的。
现在考虑一个弱内存模型,在上面的示例中以顺序一致性隐式假设,当最后一个线程读取finished[i]为1时,partialSums[i]包含部分和的值。然而,如果假设弱内存模型,则此假设不成立,因为内存系统可能会将写入重新排序为finished[i]和partialSums[i]。因此,在具有弱内存模型的系统中,写入完成的数组可能发生在写入partialSums数组之前。在这种情况下,finished[i]等于1的事实并不保证partialSums[i]包含更新的值。这种区别正是顺序一致性对程序员非常友好的原因。
在弱内存模型中,同一线程发出的内存访问总是被该线程认为是按程序顺序进行的。但是,其它线程可以不同地感知内存访问的顺序。
回到确保并行加法示例正确运行的问题上。摆脱困境的唯一方法是有一种机制,确保在另一个线程读取完成[i]为1之前完成对partialSums[i]的写入。我们可以使用一种称为栅栏(fence)的通用指令,此指令确保在栅栏开始后的任何读取或写入之前完成栅栏之前发出的所有读取和写入。简单地说,我们可以通过在每条指令后插入栅栏,将弱内存模型转换为顺序一致的模型。然而,这可能会导致大量开销,最好在需要时引入最少数量的栅栏指令。下面通过添加围栏指令,为弱内存模型并行添加一组数字。
/* variable declaration */
int partialSums[N];
int finished[N];
int numbers[SIZE];
int result = 0;
/* initialise all the elements in partialSums and finished to 0 */
(...)
/* fence */
/* 确保并行部分可以读取初始化的数组 */
fence();
/* All the data is present in all the arrays at this point */
/* parallel section */
parallel
{
/* get the current thread id */
int myId = getThreadId();
/* compute the partial sum */
int startIdx = myId * SIZE/N;
int endIdx = startIdx + SIZE/N;
for(int jdx = startIdx; jdx < endIdx; jdx++)
partialSums[myId] += numbers[jdx];
/* fence */
/* 确保在partialSums[i]之后写入finished[i] */
fence();
/* set the value of done */
finished[myId] = 1;
}
/* wait till all the threads are done */
do
{
flag = 1;
for (int i=0; i < N; i++)
{
if(finished[i] == 0)
{
flag = 0;
break;
}
}
} while (flag == 0) ;
/* sequential section */
for(int idx=0; idx < N; idx++)
result += partialSums[idx];
上述代码显示了弱内存模型的代码,代码与顺序一致内存模型的代码大致相同,唯一的区别是我们增加了两个额外的栅栏指令。我们假设一个名为fence()的函数在内部调用fence指令,在调用所有并行线程之前,首先调用fence(),确保初始化数据结构的所有写入都已完成。随后开始并行线程,并行线程完成计算和写入部分和的过程,然后再次调用fence操作,以确保在完成[myId]设置为1之前,所有部分和都已计算并写入内存中各自的位置。其次,如果最后一个线程读取finished[i]为1,就可以确定partialSums[i]的值是最新的并且正确的。因此,尽管内存模型较弱,该程序仍能正确执行。
因此,如果程序员意识到弱内存模型并在正确的位置插入栅栏,那么弱内存模型不会影响正确性。尽管如此,程序员有必要理解弱内存模型,否则,会因为程序员没有考虑底层内存模型,导致并行程序中会出现很多细微的错误。弱内存模型目前被大多数处理器使用,因为它们允许我们构建高性能内存系统。相比之下,顺序一致性非常有限,除了MIPS R10000,没有其他主要供应商提供具有顺序一致性的机器,目前所有基于x86和ARM的机器都使用不同版本的弱内存模型。
19.7.4.2 物理视角
我们研究了多处理器内存系统逻辑视图的两个重要方面,即连贯性和一致性,需要实现一个兼顾这两个属性的内存系统。本节将研究多处理器内存系统的设计空间,并提供设计备选方案的概述。为多处理器存储器系统设计高速缓存有两种方法:第一种设计称为共享缓存,其中单个缓存在多个处理器之间共享。第二种设计使用一组专用缓存,其中每个处理器或一组处理器通常都有一个专用缓存。所有的私有缓存协作提供共享缓存的错觉,这就是所谓的缓存一致性(cache coherence)。
本节将研究共享缓存的设计和私有缓存的设计,介绍确保内存一致性的问题,最终将得出结论,有效实现给定的一致性模型(如顺序一致性或弱一致性)是困难的,并且是高级计算机体系结构课程中的一个研究主题,本文提出了一个简单的解决方案。
多处理器内存系统:共享和私有缓存
首先考虑一级缓存。可以给每个处理器单独的指令缓存,指令表示只读数据,通常在程序执行期间不会改变。由于共享不是问题,所以每个处理器都可以从其小型专用指令缓存中受益,主要问题是数据缓存。设计数据缓存有两种可能的方法,可以有共享缓存,也可以有私有缓存。共享缓存是所有处理器都可以访问的单个缓存,私有缓存只能由一个处理器或一组处理器访问。可以有共享缓存的层次结构,也可以有私有缓存的层次结构,甚至可以在同一系统中有共享和私有缓存的组合,如下图所示。
具有共享和私有缓存的系统示例。
现在评估一下共享缓存和私有缓存之间的权衡。共享缓存可供所有处理器访问,并且包含缓存内存位置的单个条目,通信协议很简单,就像任何常规缓存访问一样。额外的复杂性主要是因为我们需要正确地调度来自不同处理器的请求。然而,以简单为代价,共享缓存也有其问题,为了服务来自所有处理器的请求,共享缓存需要有大量的读写端口来同时处理请求。不幸的是,缓存的大小大约是端口数的平方。此外,共享缓存需要容纳当前运行的所有线程的工作集,因此,共享缓存往往变得非常大和缓慢。由于物理限制,很难在所有处理器附近放置共享缓存。相比之下,私有缓存通常要小得多,服务请求的核心更少,读/写端口数量更少。因此,它们可以放置在与其关联的处理器附近。因此,私有缓存的速度要快得多,因为它可以放在离处理器更近的地方,而且大小也要小得多。
为了解决共享缓存的问题,设计者经常使用私有缓存,尤其是在内存层次结构的更高层。私有缓存只能由一个处理器或一小组处理器访问,它们体积小,速度快,耗电量小。私有缓存的主要问题是它们需要为程序员提供共享缓存的假象,例如,一个具有两个处理器的系统,以及与每个处理器关联的专用数据缓存。如果一个处理器写入内存地址x,则另一个处理器需要知道该写入。然而,如果它只访问其私有缓存,那么它将永远不会知道写入地址x,意味着写入地址x丢失,因此系统不一致。因此,需要绑定所有处理器的私有缓存,使它们看起来像一个统一的共享缓存,并遵守一致性规则。缓存上下文中的一致性通常称为缓存一致性(cache coherence)。保持缓存一致性是私有缓存的另一个复杂性来源,并限制了其可扩展性。它适用于小型私人缓存,然而,对于更大的私有缓存,维护一致性的开销变得令人望而却步。对于大型低级别缓存,共享缓存更合适。其次,通常会跨多个私有缓存进行一些数据复制,但会浪费空间。
一组私有缓存上下文中的一致性称为缓存一致性(cache coherence)。
通过实现缓存一致性协议,可以将一组不相交的私有缓存转换为软件共享缓存。下表概述共享缓存和私有缓存之间的主要权衡。
属性 | 私有缓存 | 共享缓存 |
---|---|---|
面积 | 低 | 高 |
速度 | 快 | 慢 |
接近处理器 | 近 | 远 |
尺寸扩展性 | 低 | 高 |
数据复制 | 是 | 否 |
复杂度 | 高(需缓存一致性) | 低 |
从表中可以清楚地看出,一级缓存最好是私有的,因为可获得低延迟和高吞吐量。然而,较低级别需要更大的尺寸,并且服务的请求数量要少得多,因此它们应该包括共享缓存。接下来描述一致的私有缓存和大型共享缓存的设计。为了简单起见,只考虑单层私有缓存,而不考虑分层私有缓存,它们会引入额外的复杂性。先讨论共享缓存的设计,因为它们更简单。
19.7.4.3 共享缓存
在共享缓存的最简单实现案例中,可以将其实现为单处理器中的常规缓存,但在实践中它被证明是一种非常糟糕的方法,原因是在单处理器中,只有一个线程访问缓存;然而在多处理器中,多个线程可能会访问缓存,因此我们需要提供更多的带宽。如果所有线程都需要访问相同的数据和标记数组,那么要么请求必须暂停,要么必须增加数组中的端口数,导致面积和功率产生非常负面的后果。最后,根据摩尔定律,缓存大小(尤其是L2和L3)大致加倍,如今片上缓存的大小可达4-16 MB甚至更多。如果对整个缓存使用单个标签数组,那么它将非常大且速度很慢。术语最后一级缓存(last level cache,LLC)定义为在内存层次结构中位置最低的片上缓存(主内存最低),例如,如果多核处理器有一个连接到主内存的片上L3高速缓存,那么LLC就是L3高速缓冲内存。后面会经常使用术语LLC。
要创建一个可以同时支持多个线程的多兆字节LLC,需要将其拆分为多个子缓存。假设有一个4 MB的LLC,在一个典型的设计中,它将被分成8-16个更小的子缓存(subcache),每个子缓存的大小为256-512 KB,这是可接受的大小。每个子缓存本身就是一个缓存,称为缓存库(cache bank)。因此,实际上将一个大型缓存拆分为一组缓存库,缓存库可以是直接映射的,也可以设置为关联的。访问多库缓存有两个步骤:首先计算库地址,然后在库执行常规缓存访问。用一个例子来解释,考虑一个16组、4 MB的缓存,每个库包含256KB的数据,4 MB=222222字节,可以将位19-22专用于选择存地址。注意,在这种情况下,库选择与关联性无关。选择一个库后,可以在块内的偏移量、集合索引和标签之间分割剩余的28位。
将缓存划分为多个库有两个优点。第一,减少了每个库的争用量。如果我们有4个线程和16个库,那么2个线程访问同一库的概率很低。其次,由于每个库都是一个较小的缓存,因此它更省电、更快。因此,我们实现了支持多线程和设计快速缓存的双重目标。
19.7.4.4 私有缓存
我们的目的是使一组私有缓存的行为就像是一个大型共享缓存,从软件的角度来看,我们不应该知道缓存是私有的还是共享的。系统的概念图如下图所示,它显示了一组处理器及其相关缓存,这组缓存形成一个缓存组,整个缓存组需要显示为一个缓存。
具有许多处理器及其私有缓存的系统。其中左侧是软件视角,而右侧是硬件视角。
这些缓存通过内部网络连接,内部网络可以从简单的共享总线类型拓扑到更复杂的拓扑。假设所有缓存都连接到共享总线,共享总线允许在任何时间点使用单个写入器和多个读取器。如果一个缓存将消息写入总线,那么所有其他缓存都可以读取该消息。拓扑结构如下图所示。请注意,总线在写入消息的任何时间点只提供对一个缓存的独占访问,因此所有缓存都感知到相同的消息顺序。一种与连接在共享总线上的缓存实现缓存一致性的协议称为监听协议(snoopy protocol)。
ng)
与共享总线连接的缓存。
现在让我们从一致性的两个公理的角度来考虑史努比协议的操作:写入总是完成(完成公理),并且所有处理器都以相同的顺序看到对同一块的写入(顺序公理)。如果缓存i希望对一个块执行写入操作,那么该写入需要最终对所有其他缓存可见。我们需要这样做来满足完成公理,因为不允许丢失写请求。其次,对同一块的不同写入需要以相同的顺序到达可能包含该块的所有缓存(顺序公理),以确保对于任何给定的块,所有缓存感知到相同的更新顺序。共享总线自动满足顺序公理的要求。
下面给出两个监听协议的设计:写更新(write-update)和写无效(write-invalidate)。
写更新协议
现在让我们设计一个协议,假设一个私有缓存保存一个写请求的副本,并将写请求广播到所有缓存。此策略确保写入永远不会丢失,并且所有缓存都以相同的顺序感知到同一块的写入消息。此策略要求无论何时写入都要广播,是一个很大的额外开销,然而,这一策略依然奏效。
现在将读取纳入协议。对位置x的读取可以首先检查私有缓存,以查看其副本是否已经可用。如果有效副本可用,则可以将该值转发给请求处理器。但是,如果存在缓存未命中,那么它可能与缓存组中的另一个姐妹缓存一起存在,或者可能需要从较低级别获取。首先检查该值是否存在于姐妹缓存中,此处遵循相同的流程,缓存向所有缓存广播读取请求,如果任何一个缓存具有该值,则它会进行回复,并将该值发送到请求缓存。请求缓存插入该值,并将其转发给处理器。但是,如果它没有从任何其他缓存获得任何回复,那么它将启动对较低级别的读取。
该协议称为写更新(write-update)协议。每个缓存块需要保持三种状态:M、S和I。M表示修改后的状态,表示缓存已经修改了块,S(共享)表示缓存未修改块,I(无效)表示块不包含有效数据。
下图显示了每个缓存块的有限状态机(FSM),该FSM由高速缓存控制器执行,状态转换的格式是:事件/动作。如果缓存控制器被发送了一个事件,那么它会采取相应的动作,可能包括状态转换。请注意,在某些情况下,动作字段为空,意味着在这些情况下,不采取任何行动。请注意,缓存块的状态是其在标记数组中的条目的一部分,如果缓存中不存在块,则其状态被假定为无效(I)。值得一提的是,下图显示了处理器生成的事件的转换,它不显示缓存组中其他缓存通过总线发送的事件的操作。
写更新协议中的状态转换图。
所有块最初都处于I状态。如果存在读取未命中,则它将移动到S状态,还需要向缓存组中的所有缓存广播读未命中,并从姊妹缓存或较低级别获取值。请注意,我们首先优先考虑姊妹缓存,因为它可能修改了块而没有将其写回较低级别。类似地,如果在I状态中存在写入未命中,那么需要从另一个姊妹缓存中读取块(如果它可用),并移动到M状态。如果没有其他姐妹缓存具有该块,那么需要从内存层次结构的较低级别读取该块。
如果在S状态下有读取命中,就可以无缝地将数据传递给处理器。但如果要写入S状态的块,就需要将写入广播到所有其他缓存,以便它们获得更新的值。一旦缓存从总线获取了其写入请求的副本,它就可以将值写入块,并将其状态更改为M。要将处于S状态的块逐出,只需要将其从缓存中逐出,此时没有必要写回其值,因为块尚未修改。
现在考虑M状态。如果需要读取M状态的块,那么可以从缓存中读取它,并将值发送给处理器。没有必要发送任何消息,但如果希望写入它,则需要在总线上发送写入请求。一旦缓存看到自己的写入请求到达共享总线,它就可以将其值写入其专用缓存中的内存位置。要回收M状态的块,需要将其写回内存层次结构中的较低级别,因为它已被修改。
每个总线都有一个称为仲裁器(arbiter)的专用结构,它接收来自不同缓存的使用总线的请求,按FIFO顺序将总线分配给缓存。总线仲裁器的示意图如下图所示,是一个非常简单的结构,包含一个在总线上传输的请求队列。每个周期它从队列中提取一个请求,并向相应的缓存授予在总线上传输消息的权限。
总线仲裁器结构。
现在考虑一个姐妹缓存。每当它从总线收到一条未命中消息时,它就会检查缓存以确定是否有该块。如果有缓存命中,它就将块发送到总线上,或直接发送到请求缓存。它如果接收到另一个缓存的写入通知,就会更新其缓存中存在的块的内容。
目录协议
请注意,在监听协议中,我们总是广播写入、读取未命中或写入未命中,实际上只需要向那些包含块副本的缓存发送消息。目录协议(directory protocol)使用称为目录的专用结构来维护此信息,对于每个块地址,目录维护一个共享者列表。共享者是可能包含该块的缓存的id,共享者列表通常是可能包含给定块的缓存的超集。我们可以将共享者列表保持为位向量(每个共享者1位),如果位为1,则缓存包含一个副本,否则不包含。
带有目录的写更新协议修改如下。缓存不是在总线上广播数据,而是将其所有消息发送到目录。对于读或写未命中,目录从姊妹缓存中获取块(如果它有副本),然后它将块转发到请求缓存。类似地,对于写入,目录只将写入消息发送到那些可能有块副本的缓存,当缓存插入或回收块时,需要更新共享者列表。最后,为了保持一致性,目录需要确保所有缓存以相同的顺序获取消息,并且不会丢失任何消息。目录协议最大限度地减少了需要发送的消息的数量,因此更具可扩展性。
监听协议(Snoopy Protocol):在监听协议中,所有缓存都连接到共享总线。缓存将每条消息广播到其他缓存。
目录协议(Directory Protocol):在目录协议中,通过添加一个称为目录的专用结构来减少消息的数量。该目录维护可能包含块副本的缓存列表,只向列表中的缓存发送给定块地址的消息。
为什么需要等待总线的广播来执行写入?
答:让我们假设情况并非如此,处理器1希望将1写入x,处理器2希望将2写入x。然后,它们将首先分别将1和2写入x的副本,然后广播写入,因此两个处理器将以不同的顺序看到对x的写入。这违反了秩序公理。但是,如果它们等待写入请求的副本从总线到达,那么它们将以相同的顺序写入x。总线有效地解决了处理器1和2之间的冲突,并对一个请求进行排序。
写无效协议
我们需要注意的是,为每次写入广播写入请求是不必要的开销,有可能大多数块在一开始就不共享,所以不需要在每次写入时发送额外的消息。让我们尝试通过提出写无效协议来减少写更新协议中的消息数量,此处可以使用监听协议,也可以使用目录协议。下面展示一个监听协议的示例。
为每个块保持三个状态:M、S和I,但改变状态的含义:
- 无效状态(I)保留相同含义,意味着该条目实际上不存在于缓存中。
- 共享状态(S)意味着缓存可以读取块,但不能写入块,在共享状态下,可以在不同的缓存中拥有同一块的多个副本。由于共享状态假定块是只读的,因此具有块的多个副本不会影响缓存一致性。
- M(已修改)状态表示缓存可以写入块。如果一个块处于M状态,那么缓存组中的所有其他缓存都需要使该块处于I状态。不允许任何其他缓存具有S或M状态的块的有效副本,这就是写无效协议不同于写更新协议的地方。它一次只允许一个写入,或者一次允许多个读取,绝不允许读和写同时共存。通过限制在任何时间点对块具有写访问权限的缓存数量,可以减少消息的数量。
内部机制如下。写更新协议不必在读命中时发送任何消息,所以当写命中时发送了额外的消息,我们希望消除之。它需要发送额外的消息,因为多个缓存可以同时读取或写入一个块。写无效协议已经消除了这种行为,如果一个块处于M状态,那么没有其他缓存包含该块的有效副本。
下图显示了由于处理器的动作而导致的状态转换图,状态转换图与写更新协议的状态转换图基本相同。让我们看看差异。第一种是,我们定义了三种类型的消息放在总线上,即写入、写入未命中和读取未命中。当从I状态转换到S状态时,将读取未命中放在总线中。如果姊妹高速缓存没有回复数据,则高速缓存控制器从较低级别读取块。S状态的语义保持不变,要写入S状态的块,我们需要在总线上写入写入消息后转换到M状态。现在,当一个块处于M状态时,可以确信没有其他缓存包含有效副本,可以自由地读写M状态的块,没有必要在总线上发送任何信息。如果处理器决定将M状态的块逐出,则需要将其数据写入较低级别。
由于处理器的动作导致的块的状态转换图。
下图显示了由于总线上接收到的消息而导致的状态转换。在S状态下,如果我们得到一个读未命中,那么这意味着另一个缓存想要对该块进行读访问。包含该块的任何缓存都会将该块的内容发送给它。这个过程可以按如下方式编排。所有具有块副本的缓存都试图访问总线。访问总线的rst缓存将块的副本发送到请求缓存。其余的缓存立即知道块的内容已被传输。他们随后停止了尝试。如果我们在S状态下收到写入或写入未命中消息,那么块将转换到I状态。
现在让我们考虑M状态。如果某个其他缓存发送写入未命中消息,则包含该块的缓存的缓存控制器将向其发送块的内容,并转换为I状态。但是,如果发生读取未命中,则需要执行一系列步骤,假设可以无缝地回收处于S状态的块,因此,有必要在移动到S状态之前将数据写入较低级别。随后,原本具有块的高速缓存也将块的内容发送到请求高速缓存,并将块的状态转换为S状态。
由于总线上的消息导致的块状态转换图。
使用目录的写无效协议
使用目录实现写无效协议相当简单。状态转换图几乎保持不变,没有广播消息,而是将其发送到目录,目录将消息发送给块的共享者。
现在阐述块的生命周期。每当从较低级别引入块时,都会初始化一个目录条目,它只有一个共享者,是从较低级别带来它的缓存。现在,如果块中存在读取未命中,则目录会继续添加共享程序。但如果存在写入未命中,或者处理器决定写入块,则会向目录发送写入或写入未命中消息。该目录清理共享者列表,并只保留一个共享者,即执行写访问的处理器。当一个块被逐出时,它的缓存会通知目录,目录会删除一个共享程序。当共享者集变空时,可以删除目录条目。
可以通过添加一个称为独占(Exclusive,E)状态的附加状态来改进写无效和更新协议,E状态可以是从存储器层次结构的较低级别获取的每个缓存块的初始状态,此状态存储块独占地属于缓存的事实。但是,缓存对其具有只读访问权限,而没有写访问权限。对于E到M的转换,不必在总线上发送写未命中或写消息,因为块只由一个缓存拥有。如果需要,可以无缝地将数据从E状态中逐出。
19.7.4.5 MESI协议
为了在SMP上提供缓存一致性,数据缓存通常支持称为MESI的协议。对于MESI,数据缓存包含每个标记的两个状态位,因此每行可以处于四种状态之一:
- 已修改(Modified,M):缓存中的行已被修改(与主内存不同),仅在此缓存中可用。
- 独占(Exclusive,E):缓存中的行与主内存中的行相同,不存在于任何其他缓存中。
- 共享(Shared,S):缓存中的行与主内存中的行相同,可能存在于另一个缓存中。
- 无效(Invalid,I):缓存中的行不包含有效数据。
下表总结了四种状态的含义。
M Modified | E Exclusive | S Shared | I Invalid | |
---|---|---|---|---|
此缓存行有效吗? | 是 | 是 | 是 | 否 |
内存副本是… | 过期 | 有效 | 有效 | - |
副本是否存在于其他缓存中? | 否 | 否 | 可能 | 可能 |
一个写入到此行… | 不进入总线 | 不进入总线 | 进入总线且更新缓存 | 直接进入总线 |
下图显示了MESI协议的状态图,缓存的每一行都有自己的状态位,因此状态图也有自己的实现。图a显示了由于连接到此缓存的处理器启动的操作而发生的转换,图b显示了由于在公共总线上窥探的事件而发生的转换。处理器启动和总线启动动作的单独状态图有助于阐明MESI协议的逻辑。任何时候,缓存行都处于单一状态,如果下一个事件来自所连接的处理器,则转换由图a指示,如果下一事件来自总线,则转换则由图b指示。
MESI状态转换图。
读未命中(read miss):当本地缓存中发生读未命中时,处理器启动内存读取以读取包含丢失地址的主内存行。处理器在总线上插入一个信号,提醒所有其他处理器/缓存单元窥探事务。有许多可能的结果:
- 如果另一个缓存具有独占状态的行的干净(自内存读取以来未修改)副本,则它将返回一个信号,指示它共享该行。然后,响应处理器将其副本的状态从独占转换为共享,启动处理器从主内存读取该行,并将其缓存中的行从无效转换为共享。
- 如果一个或多个缓存具有处于共享状态的行的干净副本,则每个缓存都会发出共享该行的信号。启动处理器读取该行并将其缓存中的行从无效转换为共享。
- 如果另一个缓存具有该行的修改副本,则该缓存将阻止内存读取,并通过共享总线将该行提供给请求缓存,然后响应缓存将其行从修改更改为共享。发送到请求缓存的行也由内存控制器接收和处理,该控制器将块存储在内存中。
- 如果没有其他缓存具有该行的副本(清除或修改),则不会返回任何信号。启动处理器读取该行并将其缓存中的行从无效转换为独占。
读命中(read hit):当读命中发生在本地缓存中的当前行上时,处理器只需读取所需的项。没有状态更改:状态保持修改、共享或独占状态。
写未命中(write miss):当本地缓存中发生写未命中时,处理器启动内存读取以读取包含丢失地址的主内存行。为此,处理器在总线上发出一个信号,表示读取意图修改(read-with-intent-to-modify,RWITM)。加载该行后,将立即标记为已修改。对于其他缓存,加载数据行之前有两种可能的情况:
- 首先,某些其他缓存可能具有该行的已修改副本(状态=修改)。在这种情况下,报警处理器向启动处理器发出信号,表示另一个处理器具有该行的修改副本,发起处理器放弃总线并等待。另一个处理器获得对总线的访问权,将修改后的缓存线写回主存储器,并将缓存行的状态转换为无效(因为启动的处理器将修改此线)。随后,启动处理器将再次向RWITM的总线发出信号,然后从主存储器读取该行,修改缓存中的行,并将该行标记为修改状态。
- 其次,没有其他缓存具有请求行的修改副本。在这种情况下,不返回任何信号,启动处理器继续读入该行并修改它。同时,如果一个或多个缓存具有处于共享状态的行的干净副本,则每个缓存都会使其行副本无效,如果一个缓存具有排他状态的行副本,则其行副本将无效。
写命中(write hit):当本地缓存中当前行发生写命中时,效果取决于本地缓存中该行的当前状态:
- 共享:在执行更新之前,处理器必须获得该行的独占所有权,处理器在总线上发出信号,在其缓存中具有行的共享副本的每个处理器将扇区从共享转换为无效,然后发起处理器执行更新并将其行副本从共享转换为修改。
- 独占:处理器已经对该行具有独占控制权,因此它只需执行更新并将该行的副本从独占转换为已修改。
- 已修改:处理器已经对该行具有独占控制权,并将该行标记为已修改,因此它只需执行更新。
L1-L2缓存一致性:到目前为止,我们已经根据连接到同一总线或其他SMP互连设施的缓存之间的协作活动描述了缓存一致性协议。通常,这些缓存是L2缓存,每个处理器还具有一个L1缓存,该缓存不直接连接到总线,因此不能参与窥探协议,因此需要某种方案来维护两级缓存和SMP配置中所有缓存的数据完整性。
策略是将MESI协议(或任何缓存一致性协议)扩展到L1缓存,L1高速缓存中的每一行包括指示状态的位,目标如下:对于L2缓存及其对应的L1缓存中存在的任何行,L1行状态应跟踪L2行的状态。一种简单的方法是在L1缓存中采用直写策略;在这种情况下,写入是到L2高速缓存而不是到内存。L1直写策略强制对L2缓存的L1行进行任何修改,从而使其对其他L2缓存可见。使用L1直写策略要求L1内容必须是L2内容的子集。这反过来表明,二级缓存的关联性应等于或大于一级缓存的关联性。L1直写策略用于IBM S/390 SMP。
如果一级缓存具有回写策略,则两个缓存之间的关系更为复杂。有几种维护方法,但超出了本文的范围。
19.7.4.6 内存一致性模型
典型的内存一致性模型指定了同一线程发出的内存操作之间允许的重新排序类型。例如,在顺序一致性中,所有读/写访问都按程序顺序完成,所有其他线程也按程序顺序感知任何线程的内存访问。
让我们构建一个连贯的内存系统,并提供一定的保证。假设所有的写操作都与完成时间相关联,并在完成时立即执行,任何读取操作都不可能在完成之前获取写入的值。写操作完成后,对同一地址的所有读操作要么得到写操作写入的值,要么得到更新的写操作。由于假设一个一致性内存,所以所有处理器都会以相同的顺序看到对同一内存地址的所有写入操作。其次,每次读取操作都会返回最近完成的写入操作写入该地址的值。现在考虑处理器1向地址x发出写入请求,同时处理器2向同一地址x发出读取请求的情况。这种行为没有定义,读取可以获取并发写入操作设置的值,也可以获取先前的值。但是,如果读取操作获得了并发写入操作设置的值,那么任何处理器发出的所有后续读取都需要获得该值或更新的值。一旦完成读取内存位置的值,读取操作就完成了,生成其数据的写入也完成了。
现在,让我们设计一个多处理器,其中每个处理器在完成之前发出的所有内存请求之后发出一个内存请求,意味着在发出内存请求(读/写)之后,处理器会等待它完成,然后再发出下一个内存请求。具有这种特性的处理器的多处理器是顺序一致的。
现在概述一个简短的非正式证明,首先介绍一个称为访问图(access graph)的理论工具。
访问图
下图显示了两个线程的执行及其相关的内存访问序列。对于每个读或写访问,我们在访问图中创建一个圆或节点(见图(c))。在这种情况下,如果一个访问按程序顺序跟随另一个访问,或者如果来自不同线程的两个访问之间存在读写依赖关系,将在两个节点之间添加一个箭头(或边)。例如,如果在线程1中将x设置为5,而线程2中的读取操作读取x的这个值,那么x的读取和写入之间存在相关性,因此在访问图中添加了一个箭头,箭头表示目标请求必须在源请求之后完成。
内存访问的图形表示。
定义节点a和b之间的发生-之前(happens-before)关系,如果访问图中存在从a到b的路径。
发生-之前(happens-before)关系表示访问图中存在从a到b的路径。此关系表示b必须在a完成后完成其执行。
访问图是一种通用工具,用于对并发系统进行推理,由一组节点组成,其中每个节点都是指令的动态实例(通常是内存指令),节点之间有边节点之间有边,来自A-->B的边意味着B需要在A之后完成执行。在上图中,添加了两种边,即程序顺序边(program order edge)和因果关系边(causality edge)。程序顺序边表示同一线程中内存请求的完成顺序,当等待一条指令完成时,在执行下一条指令之前,同一线程的连续指令之间存在边。
因果边位于线程之间的加载和存储指令之间。例如,如果一条给定的指令写入一个值,而另一条指令在另一个线程中读取该值,我们将从存储到加载添加一条边。
为了证明顺序一致性,需要向访问图添加额外的边,见下面阐述。首先假设有一个圣人(一个知道一切的假设实体),由于假设一致性内存,所以对同一内存位置的所有存储都是按顺序排序的。此外,加载和存储到同一内存位置之间存在顺序。例如,如果将x设置为1,然后将x设置成3,然后读取t1=x,然后将x=5,则位置x有一个存储-存储-加载-存储的顺序。圣人知道每个内存位置的加载和存储之间的这种顺序,假设圣人将相应的发生在边之前添加到访问图中,在这种情况下,存储和加载之间的边是因果关系边,存储-存储和加载-存储边是一致性边的示例。
接下来描述如何使用访问图来证明系统的属性。首先,需要基于给定的程序运行,为给定的内存一致性模型M构建程序的访问图,基于内存访问行为添加了一致性和因果关系边。其次,基于一致性模型在同一线程中的指令之间添加程序顺序边。对于SC,在连续指令之间添加边,对于WC,在从属指令之间、常规指令之间和栅栏之间添加边。理解访问图是一个理论工具,但它通常不是一个实用工具,这点非常重要。我们将对访问图的属性进行推理,而不必为给定的程序或系统实际地构建访问图。
如果访问图不包含循环,就可以按顺序排列节点。现在来证明这一事实。在访问图中,如果存在从a到b的路径,那么让a称为b的祖先。可以通过遵循迭代过程来生成顺序,首先找到一个没有祖先的节点,必然有这样的一个节点,因为某些操作必须是第一个完成的(否则会有一个循环)。将其从访问图中删除,然后继续查找另一个没有任何祖先的节点,按照顺序添加每个这样的节点,如上图(d)所示。在每一步中,访问图中的节点数减少1,直到最后只剩下一个节点,它成为顺序中的最后一个节点。现在考虑这样一种情况,即没有在访问图中查找任何没有祖先的节点,只有在访问图中存在循环时才可能,因此正常情况下不可能。
按顺序排列节点相当于证明访问图符合其设计的内存模型。事实上,我们可以按顺序列出节点,而不违反任何happens-before关系,意味着执行等同于单处理器按顺序依次执行每个节点,正是一致性模型的定义。任何一致性模型都由内存指令之间的排序约束以及一致性假设组成,该定义还意味着,单处理器应该可以按顺序执行指令,而不违反任何happens-before关系。
这正是我们通过将访问图转换为等效的节点顺序列表所实现的。现在,程序顺序、因果关系和一致性边缘足以指定一致性模型的事实更加深刻。
因此,如果一个访问图(对于内存模型,M)不包含循环,可以得出一个给定的执行遵循M的结论。如果可以证明一个系统可以生成的所有可能的访问图都是非循环的,就可以得出整个系统遵循M的结果。
顺序一致性的证明
让我们证明,在发出后续内存请求之前等待内存请求完成的简单系统可以生成的所有可能的访问图(假设为SC)都是非循环的。考虑任意一个访问图G,我们必须证明,可以按顺序写入G中的所有内存访问,这样,如果节点b位于节点a之后,那么G中就没有从b到a的路径。换句话说,我们的顺序遵循访问图中所示的访问顺序。
假设访问图有一个循环,它包含一组属于同一线程t1的节点S,其中a是S中程序顺序中最早的节点,b是S中最晚的节点,显然a发生在b之前,因为按照程序顺序执行内存指令,在同一线程中启动下一个请求之前等待请求完成。对于由于因果边而形成的循环,b需要写入另一个内存读取请求(节点)读取的值,c属于另一个线程。或者,在b和属于另一线程的节点c之间可以存在相干边(coherence edge)。现在,为了存在一个循环,c需要发生在a之前。假设c和a之间有一个节点链,节点链中的最后一个节点是d,根据定义,d∉t1d∉t1,意味着d写入一个存储位置,节点a从中读取,或者有一条从d到a的相干边。因为有一条路径从节点b到节点a(通过c和d),与节点b相关联的请求必须发生在节点a的请求之前。这是不可能的,因为在节点a请求完成之前,无法执行与节点b关联的内存请求。因此,存在一个矛盾,在访问图中循环是不可能的。因此,执行在SC中。
现在澄清圣人的概念。这里的问题不是生成顺序,而是证明顺序存在,因为正在解决后一个问题,所以总是可以假设一个假设实体向访问图添加了额外的边。产生的顺序次序(sequential order)遵循每个线程的程序顺序、因果关系和基于happens-before关系的连贯性。因此,这是一个有效的顺序。
因此,可以得出结论,在我们的系统中始终可以为线程创建顺序,因此多处理器是在SC中。既然已经证明了我们的系统是顺序一致的,那么让我们描述一种用所做的假设实现多处理器的方法,可以实现如下图所示的系统。
一个简单的顺序一致的系统。
简单顺序一致机器
上图显示了一种设计,它在多处理器的所有处理器上都有一个大的共享L1缓存,每个内存位置只有一个副本,在任何单个时间只能支持一次读或写访问,确保了一致性。其次,当一次写入更改其在一级缓存中的内存位置的值时,写入完成。同样,当读取L1缓存中的内存地址值时,读取完成。我们需要修改前面章节描述的简单有序RISC流水线,以便指令仅在完成读/写访问后才离开内存访问(MA)阶段。如果存在缓存未命中,则指令等待直到块到达一级缓存,并且访问完成。这个简单的系统确保来自同一线程的内存请求按程序顺序完成,因此顺序一致。
请注意,上图描述的系统做出了一些不切实际的假设,因此不实用。如果我们有16个处理器,并且内存指令的频率是1/3,那么每个周期都需要5-6条指令访问一级缓存。因此,一级缓存需要至少6个读/写端口,使得结构太大和太慢。此外,L1缓存需要足够大以容纳所有线程的工作集,进一步使得L1缓存非常大且速度慢。因此,具有这种高速缓存的多处理器系统在实践中将非常缓慢,而现代处理器选择了更高性能的实现,其内存系统更复杂,有很多更小的缓存。这些缓存相互协作,以提供更大缓存的错觉。
很难证明一个复杂系统遵循顺序一致性(SC),设计师选择设计具有弱内存模型的系统。在这种情况下,我们需要证明栅栏指令正确工作,如果考虑复杂设计中可能出现的所有细微角落情况,也是一个相当具有挑战性的问题。
实现弱一致性模型
考虑弱一致系统的访问图,没有边来表示同一线程中节点的程序顺序。相反,对于同一线程中的节点,在常规读/写节点和栅栏操作之间有边缘,需要将因果关系和相干边添加到访问图中,就像对SC的情况所做的那样。
弱一致机器的实现需要确保该访问图没有循环。我们可以证明,下面的实现没有向访问图引入循环,确保在给定线程的程序顺序中的所有先前指令完成后,栅栏指令开始。fence指令是一条伪指令,只需要到达管线的末端,仅用于计时目的。我们在MA阶段暂停栅栏指令,直到前面的所有指令完成,该策略还确保没有后续指令到达MA阶段。一旦所有先前的指令完成,围栏指令进入RW阶段,随后的指令可以向内存发出请求。
总结一下实现内存一致性模型的内容。通过修改处理器的流水线,并确保存储器系统一旦完成对存储器请求的处理就向处理器发送确认,可以实现诸如顺序一致性或弱一致性的内存一致性模型。在高性能实现中,许多细微的角落情况是可能的,确保它们实现给定的一致性模型相当复杂。
关于内存屏障的应用和UE的实现,可参阅1.4.5 内存屏障。
19.7.4.7 多线程处理器
现在看看设计多处理器的另一种方法。到目前为止,我们一直坚持需要有物理上分离的管线来创建多处理器,研究了为每个管线分配单独程序计数器的设计。然而,让我们看看在同一管线上运行一组线程的不同方法,这种方法被称为多线程(multithreading)。不在单独的管线上运行单独的线程,而是在同一管道上运行它们。通过讨论称为粗粒度多线程的最简单的多线程变体来说明这个概念。
多线程(multithreading)是一种设计范式,在同一管线上运行多个线程。
多线程处理器(multithreaded processor)是实现多线程的处理器。
粗粒度多线程
假设我们希望在单个管线上运行四个线程。属于同一进程的多个线程有各自的程序计数器、堆栈和寄存器,然而,它们对内存有着共同的视图,所有这四个线程都有各自独立的指令流,因此有必要提供一种错觉,即这四个进程是单独运行的。软件应该忽略线程在多线程处理器上运行的事实,它应该意识到每个线程都有其专用的CPU。除了传统的一致性和一致性保证之外,还需要提供一个额外的保证,即软件应该忽略多线程。
考虑一个简单的方案,如下图所示,线程1执行n个循环,然后切换到线程2并运行n个周期,然后切换至线程3,以此类推。在执行线程4 n个循环后,再次开始执行线程1。要执行线程,需要加载其状态或上下文。程序的上下文包括标志寄存器、程序计数器和一组寄存器,没有必要跟踪主内存,因为不同进程的内存区域不重叠,在多个线程的情况下,明确希望所有线程共享相同的内存空间。
可以采用更简单的方法,而不是显式地加载和卸载线程的上下文,可以在管线中保存线程的上下文,例如,如果望支持粗粒度多线程,就可以有四个独立的标志寄存器、四个程序计数器和四个独立寄存器(每个线程一个),还可以有一个包含当前运行线程的id的专用寄存器。例如,如果正在运行线程2,就使用线程2的上下文,如果在运行线程3,就使用线程3的上下文。以这种方式,多个线程不可能覆盖彼此的状态。
粗粒度多线程的概念图
可以采用更简单的方法,而不是显式地加载和卸载线程的上下文。我们可以在管道中保存线程的上下文。例如,如果我们希望支持粗粒度多线程,那么我们可以有四个独立的标志寄存器、四个程序计数器和四个独立寄存器文件(每个线程一个)。此外,我们可以有一个包含当前运行线程的id的专用寄存器。例如,如果我们正在运行线程2,那么我们使用线程2的上下文,如果我们在运行线程3,我们使用线程3的上下文。以这种方式,多个线程不可能覆盖彼此的状态。
现在看看一些微妙的问题。可能在管线中的同一时间点拥有属于多个线程的指令,当从一个线程切换到下一个线程时,可能会发生这种情况。让我们将线程id字段添加到指令包中,并进一步确保转发和互锁逻辑考虑到线程的id,我们从不跨线程转发值。以这种方式,可以在管线上执行四个独立的线程,而线程之间的切换开销可以忽略不计。我们不需要使用异常处理程序来保存和恢复线程的上下文,也不需要调用操作系统来调度线程的执行。
现在整体来看一下粗粒度多线程。我们快速连续执行n个线程,并按循环顺序执行,此外,有一种在线程之间快速切换的机制,线程不会破坏彼此的状态,但仍然不会同时执行四个线程。那么,这个方案的优点是什么?
考虑一下内存密集型线程的情况,这些线程对内存有很多不规则的访问,它们将经常在二级缓存中发生未命中,其管线需要暂停100-300个周期,直到值从内存中返回。无序管线可以通过执行一些不依赖于内存值的其他指令来隐藏某些延迟,尽管如此,它也将暂停很长一段时间。然而,如果我们可以切换到另一个线程,那么它可能有一些有用的工作要做。如果该线程也来自L2缓存中的未命中,那么我们可以切换到另一个线程并完成其部分工作,这样可以最大化整个系统的吞吐量。可以设想两种可能的方案:可以每n个周期周期性地切换一次,或者在发生二级缓存未命中等事件时切换到另一个线程;其次,如果线程正在等待高延迟事件(如二级缓存丢失),不需要切换到该线程,需要切换到一个具有准备好执行指令池的线程。可以设计大量的启发式算法来优化粗粒度多线程机器的性能。
软件线程和硬件线程的区别:
- 软件线程是一个子程序,与其他软件线程共享一部分地址空间,这些线程可以相互通信以协作实现共同目标。
- 硬件线程被定义为在管线上运行的软件线程或单线程程序及其执行状态的实例。
多线程处理器通过跨线程分配资源来支持同一处理器上的多个硬件线程。软件线程可以物理地映射到单独的处理器或硬件线程,与用于执行它的实体无关。需要注意的重要一点是,软件线程是一种编程语言概念,而硬件线程在物理上与管线中的资源相关联。
本文使用“线程”一词来表示软件和硬件线程,需要根据上下文推断正确的用法。
细粒度多线程
细粒度多线程是粗粒度多线程的一种特殊情况,其中切换间隔n非常小,通常为1或2个循环,意味着可以在线程之间快速切换。我们可以利用粒度多线程来执行内存密集型线程,然而,否定多线程对于执行一组线程(例如具有长算术运算,如除法)也很有用。在典型的处理器中,除法运算和其他特殊运算(如三角运算或超越运算)很慢(3-10个周期)。在这段时间内,当原始线程等待操作完成时,可以切换到另一个线程,并在管线阶段中执行它的一些未使用的指令。因此,我们可以利用在线程之间快速切换的能力来减少具有大量数学运算的科学程序中的空闲时间。
因此,我们可以将细粒度多线程视为更灵活的粗粒度多线程形式,在线程之间快速切换,并利用空闲阶段执行有用的工作。请注意,这个概念并不像听起来那么简单。需要在常规有序或无序管线中的所有结构中为多线程提供详细的支持,需要非常仔细地管理每个线程的上下文,并确保不会遗漏指令,也不会引入错误。
线程之间切换的逻辑不是普通的。大多数时候,在线程之间切换的逻辑是基于时间的标准(周期数)和基于事件的标准(高延迟事件,如二级缓存未命中或页面错误)的组合。为了确保多线程处理器在一系列基准测试中表现良好,必须仔细调整启发式。
并发多线程
对于单个执行管线,如果可以通过使用复杂的逻辑在线程之间切换来确保每个阶段都保持忙碌,就可以实现高效率。单个执行管线中的任何阶段每个周期只能处理一条指令。相比之下,多执行流水线每个周期可以处理多个指令,此外,将执行槽的数量设计为等于流水线每个周期可以处理的指令数量。例如,一个3执行处理器,每个周期最多可以获取、解码并最终执行3条指令。
为了在多执行管线中实现多线程,还需要考虑线程中指令之间的依赖性。细粒度和粗粒度方案可能无法很好地执行,因为线程无法为所有执行槽向功能单元执行指令,这种线程可描述成具有低指令级并行性。如果我们使用4个执行流水线,并且由于程序中的依赖性,每个线程的最大IPC为1,那么每个周期中有3个执行槽将保持空闲。因此,4线程系统的总体IPC将为1,多线程的好处将受到限制。
因此,有必要利用额外的执行时段,以便我们能够增加整个系统的IPC。一种简单的方法是为每个线程分配一个执行槽。其次,为了避免结构冲突,可以有四个ALU,并为每个线程分配一个ALU。然而,这是对管线的次优利用,因为线程可能没有执行每个周期的指令。最好有一个更灵活的方案,可以在线程之间动态地划分执行槽,这种方案被称为并发多线程(simultaneous multithreading,SMT)。例如,在给定的周期中,我们可能会从线程2执行2条指令,从线程3和4执行1条指令,这种情况可能在下一个周期中发生逆转。下图说明这个概念,同时还将SMT方法与细粒度和粗粒度多线程进行比较。
多线程处理器中的指令执行。
上图中的列表示多执行机器的执行槽,行表示周期,属于不同线程的指令有不同的颜色。图(a)显示了粗粒度机器中指令的执行情况,其中每个线程执行两个连续的周期。由于没有找到足够数量的可执行指令,所以很多执行槽都是空的,细粒度多线程(图(b))也有同样的问题。然而,在SMT处理器中,通常能够使大多数执行槽保持忙碌,因为总是从准备执行的可用线程集中找到指令。如果一个线程由于某种原因被暂停,其他线程会通过执行更多的指令进行补偿。实际上,所有线程不同时具有低ILP(Instruction Level Parallelism,指令级并行)阶段,因此,SMT方法已被证明是一种非常通用且有效的方法,可以利用多个执行处理器的能力。自从奔腾4(90年代末发布)以来,大多数英特尔处理器都支持不同版本的同时多线程,在英特尔的术语中,SMT被称为超线程,而IBM Power 7处理器有8个内核,每个内核都是4路SMT(每个内核可以运行4个线程)。
请注意,选择要执行的正确指令集的问题对SMT处理器的性能至关重要。其次,n路SMT处理器的内存带宽要求高于等效单处理器,提取逻辑也要复杂得多,因为需要在同一周期内从四个独立的程序计数器中提取。最后,保持连贯性和一致性的问题使情况更加复杂。
执行多个线程的更多方法如下:
它们的说明如下:
- 超标量(Superscalar):是没有多线程的基本超标量方法,直到前些年,依旧是在处理器内提供并行性的最强大的方法。注意,在某些周期中,并非使用所有可用的执行槽(issue slot),在这些周期中,发出的指令数少于最大指令数,被称为水平损耗(horizontal loss)。在其他指令周期中,不使用执行槽,是无法发出指令的周期,被称为垂直损耗(vertical loss)。
- 交错多线程超标量(Interleaved multithreading superscalar):在每个周期中,从一个线程发出尽可能多的指令。如前所述,通过这种技术,消除了由于线程切换导致的潜在延迟,但在任何给定周期中发出的指令数量仍然受到任何给定线程中存在的依赖性的限制。
- 阻塞的多线程超标量(Blocked multithreaded superscalar):同样,在任何周期中,只能发出来自一个线程的指令,并且使用阻塞多线程。
- 超长指令字(Very long instruction word,VLIW):VLIW架构(如IA-64)将多条指令放在一个字中,通常由编译器构建,它将可以并行执行的操作放在同一个字中。在一个简单的VLIW机器(上图g)中,如果不可能用并行发出的指令完全填充单词,则不使用操作。
- 交错多线程VLIW(Interleaved multithreading VLIW):提供与超标量架构上交错多线程所提供的效率类似的效率。
- 阻塞多线程VLIW(Blocked multithreaded VLIW):提供与超标量架构上的阻塞多线程所提供的效率类似的效率。
- 同时多线程(Simultaneous multithreading):上图j显示了一个能够一次发出8条指令的系统。如果一个线程具有高度的指令级并行性,则它可能在某些周期内能够填满所有的水平槽。在其他周期中,可以发出来自两个或多个线程的指令。如果有足够的线程处于活动状态,则通常可以在每个周期中发出最大数量的指令,从而提供较高的效率。
- 芯片多处理器(Chip multiprocessor),亦即多核(multicore):上图k显示了一个包含四个核的芯片,每个核都有一个两个问题的超标量处理器。每个内核都分配了一个线程,每个周期最多可以发出两条指令。
19.7.5 SIMD多处理器
本节讨论SIMD多处理器。SIMD处理器通常用于科学应用、高强度游戏和图形,它们没有大量的通用用途。然而,对于一类有限的应用,SIMD处理器往往优于MIMD处理器。
SIMD处理器有着丰富的历史。在过去的好日子里,我们把处理器排列成阵列,数据通常通过处理器的第一行和第一列输入,每个处理器对输入消息进行操作,生成一条输出消息,并将该消息发送给其邻居。这种处理器被称为收缩阵列(systolic array)。收缩阵列用于矩阵乘法和其他线性代数运算。随后的几家供应商,尤其是Cray,在他们的处理器中加入了SIMD指令,以设计更快、更节能的超级计算机。如今,这些早期的努力大多已经隐退,然而,经典SIMD计算机的某些方面,即单个指令对多个数据流进行操作,已经渗透到现代处理器的设计中。
我们将讨论现代处理器设计领域的一个重要发展,即在高性能处理器中加入SIMD功能单元和指令。
19.7.5.1 SIMD:矢量处理器
让我们考虑添加两个n元素数组的问题。在单线程实现中,需要从内存加载操作数,添加操作数,并将结果存储在内存中。因此,为了计算目标数组的每个元素,需要两条加载指令、一条加法指令和一条存储指令。传统处理器试图通过利用可以并行计算(c[i]=a[i]+b[i])和(c[j]=a[j]+b[j])的事实来实现加速,因为这两个运算之间没有任何依赖关系,可以通过并行执行许多这样的操作来增加IPC。
现在让我们考虑超标量处理器。如果它们每个周期可以执行4条指令,那么它们的IPC最多可以是单周期处理器的4倍。在实践中,对于4执行的处理器,我们可以通过这种固有的并行阵列处理操作在单周期处理器上实现的峰值加速大约是3到3.5倍。其次,这种通过宽执行宽度来增加IPC的方法是不可扩展的。在实践中没有8或10个执行处理器,因为流水线的逻辑变得非常复杂,并且面积/功率开销变得令人望而却步。
因此,设计人员决定对对大型数据向量(数组)进行操作的向量操作提供特殊支持,这种处理器被称为矢量处理器,主要思想是一次处理整个数据数组。普通处理器使用常规标量数据类型,如整数和浮点数;而向量处理器使用向量数据类型,本质上是标量数据类型的数组。
向量处理器(vector processor)将原始数据类型(整数或浮点数)的向量视为其基本信息单位。它可以一次加载、存储和执行整个向量的算术运算,这种对数据向量进行操作的指令称为向量指令(vector instruction)。
使用多个功能单元来提高单个向量加法C=A+B指令的性能。
包含四通道的矢量单元的结构。
主要使用矢量处理器的最具标志性的产品之一是Cray 1超级计算机,这种超级计算机主要用于主要由线性代数运算组成的科学应用,这样的操作适用于数据和矩阵的向量,因此非常适合在向量处理器上运行。可悲的是,在高强度科学计算领域之外,向量处理器直到90年代末才进入通用市场。
90年代末,个人计算机开始用于研究和运行科学应用。其次,设计师们开始使用普通商品处理器来建造超级计算机,而不是为超级计算机设计定制处理器。从那时起,这一趋势一直持续到图形处理器的发展。1995年至2010年间,大多数超级计算机由数千个商品处理器组成。在常规处理器中使用矢量指令的另一个重要原因是支持高强度游戏,游戏需要大量的图形处理,例如,现代游戏渲染具有多个角色和数千个视觉效果的复杂场景。大多数视觉效果,如照明、阴影、动画、深度和颜色处理,都是对包含点或像素的矩阵进行基本线性代数运算的核心。由于这些因素,常规处理器开始引入有限的矢量支持,特别是,英特尔处理器提供了MMX、SSE 1-4矢量指令集,AMD处理器提供了3DNow!矢量扩展,ARM处理器提供ARM Neon矢量ISA。这些ISA之间有很多共性,因此我们不用关注任何特定的ISA。让我们转而讨论矢量处理器设计和操作背后的广泛原则。
19.7.5.2 软件接口
让我们先考虑一下机器的型号,需要一组向量寄存器,例如,x86 SSE(数据流单指令多数据扩展指令集)指令集定义了16个128位寄存器(XMM0...XMM15),每个这样的寄存器可以包含四个整数或四个浮点值,或者也可以包含八个2字节短整数或十六个1字节字符。在同一行上,每个矢量ISA都需要比普通寄存器宽的附加矢量寄存器。通常,每个寄存器可以包含多个浮点值。此处不妨让我们定义八个128位矢量寄存器:vr0...vr7。
现在,我们需要指令来加载、存储和操作向量寄存器。对于加载向量寄存器,有两个选项,可以从连续内存位置加载值,也可以从非连续内存位置装载值。前一种情况更为特殊,通常适用于基于阵列的应用程序,其中所有阵列元素都存储在连续的内存位置。ISA的大多数矢量扩展都支持加载指令的这种变体,因为它的简单性和规则性。此处不妨将ISA设计这样一个向量加载指令v:ld,考虑下图中所示的语义。此处,v:ld指令将内存位置([r1+12]、[r1/16]、[r2+20]、[r 1+24])的内容读入向量寄存器vr1。在下表中,请注意是向量寄存器。
示例 | 语法 | 解释 |
---|---|---|
v.ld vr1, 12[r1] | v.ld , | vr1 <-- ([r1+12], [r1+16], [r1+20], [r1+ 24]) |
现在考虑矩阵的情况。假设有一个10000元素矩阵a[100][100]
,并假设数据是按行主顺序存储的,且要对矩阵的两列进行运算。在这种情况下,我们遇到了一个问题,因为列中的元素没有保存在相邻的位置。因此,依赖于输入操作数保存在连续内存位置的假设的向量加载指令将停止工作,需要有专门的支持来获取列中位置的所有数据,并将它们保存在向量寄存器中。这种操作称为分散-聚集(catter-gather)操作,因为输入操作数基本上分散在主内存中。
我们需要收集,并将它们放在一个叫向量寄存器的地方。让我们考虑向量加载指令的分散-聚集变体,并将其称为v.sg.ld。处理器读取另一个包含元素地址的向量寄存器,而不是假设数组元素的位置(语义见下表)。在这种情况下,专用向量加载单元读取存储在vr2中的内存地址,从内存中提取相应的值,并将它们顺序写入向量寄存器vr1。
示例 | 语法 | 解释 |
---|---|---|
v.sg.ld vr1, 12[r1] | v.sg.ld , | vr1 <-- ([vr2[0]], [vr2[1]], [vr2[2]], [vr2[3]]) |
一旦在向量寄存器中加载了数据,就可以直接对两个这样的寄存器进行操作。例如,考虑128位矢量寄存器vr1和vr2,那么,汇编语句v.add vr3, vr1, vr2
,将存储在输入向量寄存器(vr1和vr2)中的每对对应的4字节浮点数相加,并将结果存储在输出向量寄存器(vr3)中的相关位置。注意,这里使用向量加法指令(v.add)。下图显示了矢量加法指令的示例。
矢量ISA为矢量乘法、除法和逻辑运算定义了类似的操作。向量指令不必总是有两个输入操作数,即向量,可以将一个向量与一个标量相乘,也可以有一条只对一个向量操作数进行运算的指令。例如,SSE指令集有专门的指令,用于计算向量寄存器中的一组浮点数的三角函数,如sin和cos。如果一条向量指令可以同时对n个操作数执行操作,就说有n个数据通道,而向量指令同时对所有n个数据路径执行操作。
如果一条向量指令可以同时对n个操作数执行操作,那么就表示有n个数据通道(lane),而向量指令同时对所有n个数据路径执行操作。
最后一步是将向量寄存器存储在内存中,有两种选择:可以存储到相邻的内存位置,也可以保存到非相邻的位置。可以在向量加载指令的两个变体(v.ld和v.sg.ld)的行上设计向量存储指令的两种变体(连续和非连续)。有时需要引入在标量寄存器和矢量寄存器之间传输数据的指令。
19.7.5.3 SSE指令示例
现在考虑一个使用基于x86的SSE指令集的实际示例,不使用实际的汇编指令,改为使用gcc编译器提供的函数,这些函数充当汇编指令的封装器,称为gcc内建函数。
现在让我们解决添加两个浮点数数组的问题,希望对i的所有值计算c[i]=a[i]+b[i]
。
SSE指令集包含128位寄存器,每个寄存器可用于存储四个32位浮点数。因此,如果有一个N个数字的数组,需要进行N/4次迭代,因为在每个循环中最多可以添加4对数字。在每次迭代中,需要加载向量寄存器,累加它们,并将结果存储在内存中。这种基于向量寄存器大小将向量计算分解为循环迭代序列的过程称为条带开采(strip mining)。
用C/C++编写一个函数,将数组a和b中的元素成对相加,并使用x86 ISA的SSE扩展将结果保存到数组C中。假设a和b中的条目数相同,是4的倍数。一种实现的代码如下:
void sseAdd (const float a[], const float b[], float c[], int N)
{
/* strip mining */
int numIters = N / 4;
/* iteration */
for (int i = 0; i < numIters; i++)
{
/* load the values */
__m128 val1 = _mm_load_ps(a);
__m128 val2 = _mm_load_ps(b);
/* perform the vector addition */
__m128 res = _mm_add_ps(val1, val2);
/* store the result */
_mm_store_ps(c, res);
/* increment the pointers */
a += 4 ; b += 4; c+= 4;
}
}
上述的代码解析:先计算迭代次数,在每次迭代中,考虑一个由4个数组元素组成的块,将一组四个浮点数加载到128位向量变量中,val1.val1由编译器映射到向量寄存器,使用函数_mm_load_ps
从内存中加载一组4个连续的浮点值。例如,函数_mm_load_ps(a)
将位置a、a+4、a+8和a+12中的四个浮点值加载到向量寄存器中。类似地,加载第二个向量寄存器val2,从存储器地址b开始的四个浮点值。随后执行向量加法,并将结果保存在与变量res关联的128位向量寄存器中。为此,使用内建函数_mm_add_ps
,随后将变量res存储在内存位置,即c、c+4、c+8和c+12。
在继续下一次迭代之前,需要更新指针a、b和c。因为每个周期处理4个连续的数组元素,所以用4个(4个数组元素)更新每个指针。
可以很快得出结论,向量指令有助于批量计算,例如批量加载/存储,以及一次性成对添加一组数字。将此函数的性能与四核Intel core i7机器上不使用向量指令的函数版本进行了比较,带有SSE指令的代码对百万元素数组的运行速度快2-3倍。如果有更广泛的SSE寄存器,那么可以获得更多的加速,x86处理器上最新的AVX矢量ISA支持256和512位矢量寄存器。
19.7.5.4 判断指令
到目前为止,我们已经考虑了向量加载、存储和ALU操作。分支呢?通常,分支在向量处理器的上下文中具有不同的含义。例如,一个具有向量寄存器的处理器,其宽度足以容纳32个整数,有一个程序要求仅对18个整数进行成对相加,然后将它们存储在内存中。在这种情况下,无法将整个向量寄存器存储到内存中,因为有覆盖有效数据的风险。
让我们考虑另一个例子。假设想对数组的所有元素应用函数inc10(x),在这种情况下,如果输入操作数x小于10,希望将其加10。在向量处理器上运行的程序中,这种模式非常常见,因此需要向量ISA中的额外支持来支持它们。
function inc10(x):
if (x < 10)
x = x + 10;
让我们添加一个规则指令的新变体,并将其称为判断指令(predicated instruction,类似于ARM中的条件指令)。例如,我们可以创建常规加载、存储和ALU指令的判断变体。判断指令在特定条件为真时执行,否则根本不执行,如果条件为false,则判断指令等同于nop。
判断指令(predicated instruction)是正常加载、存储或ALU指令的变体。如果某个条件为真,它将正常执行;如果关联条件为false,那么它将转换为nop。
例如,如果最后一次比较结果相等,则ARM ISA中的
addeq
指令会像正常的加法指令一样执行。但是,如果不是这样,则add指令根本不执行。
现在让添加对判断的支持。首先创建cmp指令的向量形式,并将其称为v.cmp。它对两个矢量进行成对比较,并将比较结果保存在v.flags寄存器中,该寄存器是标志寄存器的矢量形式。v.flags寄存器的每个组件都包含一个E和GT字段,类似于常规处理器中的标志寄存器。
v.cmp vr1, vr2
上述语句比较vr1和vr2,并将结果保存在v.flags寄存器中。我们可以使用此指令的另一种形式,将向量与标量进行比较。
v.cmp vr1, 10
现在,让我们定义向量加法指令的谓词形式。如果v.flags[i]寄存器满足某些属性,则此指令将两个向量的第i元素相加,并更新目标向量寄存器的第i个元素。否则,它不会更新目标寄存器的第i个元素。假设判断向量add指令的一般形式为:v.p.add,p是判断条件。下表列出了p可以取的不同值。
判断条件 | 解析 |
---|---|
lt | < |
gt | > |
le | <= |
ge | >= |
eq | = |
ne | != |
现在考虑下面的代码片段:
v.lt.add vr3, vr1, vr2
此处,向量寄存器vr3的值是由vr1和vr2表示的向量之和,预测条件小于(lt),意味着,如果在v.flags寄存器中元素i的E和GT标志都为假,那么只有我们对第i个元素执行加法,并在vr3寄存器中设置其值,vr3寄存器中未被加法指令设置的元素保持其先前的值。因此,实现函数inc10(x)的代码如下,假设vr1包含输入数组的值。
v.cmp vr1, 10
v.lt.add vr1, vr1, 10
同样,我们可以定义加载/存储指令和其他ALU指令的判断版本。
19.7.6 互连网路
19.7.6.1 互联网络概述
现在考虑互连不同处理和存储元件的问题。通常,多核处理器使用棋盘设计,但此处我们将处理器集划分为块(tile,亦称瓦片),块通常由一组2-4个处理器组成,具有其专用缓存(L1和可能的L2),它还包含共享的最后一级缓存(L2或L3)的一部分。共享的最后一级缓存的一部分是给定分片的一部分,称为分片(slice),分片由2-4个存储库(bank)组成。此外,在现代处理器中,一个块或一组块可能共享一个内存控制器,内存控制器的作用是协调片上高速缓存和主内存之间的数据传输。下图显示了32核多处理器的代表性布局,与缓存组相比,内核的颜色更深。我们使用2个块大小(2个处理器和2个缓存组),并假设共享L2缓存具有32个均匀分布在块上的缓存组。此外,每个块都有一个专用的内存控制器和一个称为路由器(router)的结构。
多核处理器的布局。
紧密耦合多处理器的通用架构图。
集群配置。
路由器是一个专用单元,定义如下。
1、路由器通过片上网络将源自其瓦片中的处理器或缓存的消息发送到其他瓦片。
2、路由器通过片上网络相互连接。
3、消息通过一系列路由器从源路由器传送到(远程瓦片的)目的路由器。途中的每一个路由器都会将消息转发到另一个离目的地更近的路由器。
4、最后,与目的地砖相关联的路由器将消息转发到远程砖中的处理器或高速缓存。
5、相邻路由器通过链路连接,链路是一组用于传输消息的无源铜线。
6、路由器通常有许多传入链路和许多传出链路。一组传入和传出链接将其连接到处理器,并在其瓦片中缓存。每个链接都有一个唯一的标识符。
7、路由器具有相当复杂的结构,通常由3至5级管线组成。大多数设计通常将管线阶段用于缓冲消息、计算传出链路的id、仲裁链路以及通过传出链路发送消息。
8、路由器和链路的布置被称为片上网络或片上网络,缩写为NOC。
9、将连接到NOC的每个路由器称为节点,节点通过发送消息相互通信。
在程序执行过程中,它通过NOC发送数十亿条消息,NOC携带一致性消息、LLC(最后一级缓存)请求/响应消息以及缓存和内存控制器之间的消息。操作系统还使用NOC向内核发送消息以加载和卸载线程,由于信息量大,大部分NOC经常遇到相当大的拥堵。因此,设计尽可能减少拥堵、易于设计和制造并确保信息快速到达目的地的NOC至关重要。让我们定义NOC的两个重要特性:等分带宽(bisection bandwidth)和直径(diameter)。
对称多处理器组织如下:
19.7.6.2 等分带宽和网络直径
让我们考虑一个网络拓扑,其中顶点是节点,顶点之间的边是链接。假设存在链路故障,或者由于拥塞等其他原因,链路不可用,那么应该可以通过备用路径路由消息。例如,考虑一个布置为环的网络,如果一个链路失败,那么总是可以通过环的另一端发送消息。如果以顺时针方式发送消息,可以以逆时针方式发送。然而,如果存在两个链路故障,则网络可能会断开成两个相等的部分。因此,我们希望最大限度地减少将网络完全断开成相当大的部分(可能相等)所需的链路故障数量。将此类故障的数量称为等分带宽(bisection bandwidth),等分带宽是衡量网络可靠性的指标,它精确地定义为需要将网络划分为两个相等部分的最小链路数。
可以对等分带宽进行另一种解释。假设一半网络中的节点正在尝试向另一半网络中节点发送消息,那么可以同时发送的消息数量至少等于等分带宽。因此,等分带宽也是网络带宽的度量。
等分带宽(bisection bandwidth)被定义为将网络分成两个相等部分所需的最小链路故障数。
我们已经讨论了可靠性和带宽,现在转向关注延迟。考虑网络中的节点对,接下来考虑每对节点之间的最短路径,在所有这些最短路径中,考虑具有最大长度的路径,该路径的长度是网络中节点接近度的上限,称为网络直径(Network Diameter)。或者,可以将网络的直径解释为任何一对节点之间最坏情况下延迟的估计。
让我们考虑所有节点对,并计算每对节点之间的最短路径,最长路径的长度称为网络直径(Network Diameter),它是网络最坏情况下延迟的度量。
19.7.6.3 网络拓扑
本节回顾一些最常见的网络拓扑,其中一些拓扑用于多核处理器。然而,大多数复杂的拓扑结构用于使用常规以太网链路连接处理器的松散耦合多处理器。对于每个拓扑,假设它有N个节点,为了计算等分带宽,可以进一步简化N可被2整除的假设。请注意,等分带宽和直径等度量都是近似度量,仅表示广泛的趋势。因此,我们有余地做出简单的假设,从考虑适用于多核的更简单拓扑开始,要以高的等分带宽和低的网络直径为目标。
链和环
下图左显示了一个节点链,它的等分带宽为1,网络直径为N-1,是最糟糕的配置。可以通过考虑一个节点环来改进这两个指标(下图右),此时等分带宽为2,网络直径为N=2。这两种拓扑都相当简单,已被其他拓扑所取代。现在考虑一种称为胖树的拓扑结构,它通常在集群计算机中使用,集群计算机是指由通过局域网连接的多个处理器组成的松散耦合的多处理器。
集群计算机(cluster computer)是指由通过局域网连接的多个处理器组成的松散耦合计算机。
左:链;右:环。
胖树
下图显示了一棵胖树(fat tree),所有节点都在叶子上,树的所有内部节点都是专用于路由消息的路由器,将这些内部节点称为交换机。从节点A到节点b的消息首先传播到最近的节点,该节点是A和b的共同祖先,然后它向下传播到b。请注意,消息的密度在根附近最高,为了避免争用和瓶颈,当向根节点移动时,逐渐增加连接节点及其子节点的链接数。该策略减少了根节点处的消息拥塞。
在示例中,两个子树连接到根节点,每个子树有4个节点,根最多可以从每个子树接收4条消息。其次,它最多需要向每个子树发送4条消息。假设一个双工链接,根需要有4个链接将其连接到其每个子级。同样,下一级节点需要它们与其每个子节点之间的2个链接,每个叶子需要一个链接。因此,当向根部前进时,可以看到这棵树越来越胖,因此它被称为胖树。
网络直径等于2log(N)2log(N),等分带宽等于将根节点连接到其每个子节点的最小链路数。假设树的设计是为了确保根上的链接绝对没有争用,那么需要用N=2个链接将根连接到每个子树,这种情况下的等分带宽为N=2。请注意,在大多数实际情况下,不会在根及其子级之间分配N=2个链路,因为子树中所有节点同时发送消息的概率很低,因此在实践中可以减少每个级别的链接数。
网格和圆环
左:网格;右:圆环。
现在看看更适合多核的拓扑,最常见的拓扑之一是网格(mesh),其中所有节点都以类似矩阵的方式连接(上图左)。拐角处的节点有两个邻居,边缘上的节点有三个邻居,其余节点有四个邻居。现在计算网格的直径和等分带宽,最长的路径在两个角节点之间,直径等于(2√N−22N−2)。要将网络分成两个相等的部分,需要在中间(水平或垂直)分割网格,因为在一行或一列中有√NN个节点,所以等分带宽等于√NN。就这些参数而言,网格优于链和环。
不幸的是,网格拓扑本质上是不对称的,位于网格边缘的节点彼此远离,可以通过每行和每列的末端之间的交叉链接来增加网格,所得结构称为圆环(torus),如上图右所示。现在看看圆环的性质,网络边缘两侧的节点只相隔一跳,最长的路径位于任何角节点和圆环中心的节点之间,直径再次等于(忽略小的相加常数)√N/2+√N/2=√NN/2+N/2=N。回想一下,圆环每边的长度等于√NN。
现在,将网络分成两个相等的部分,将其水平拆分。因此,需要捕捉√NN个垂直链接,以及√NN条交叉链接(每列末端之间的链接)。因此,等分带宽等于2√N2N。
通过添加2√N2N个交叉链接(行为√NN,列为√NN),将直径减半,并将圆环的等分带宽加倍。然而,这个方案仍然存在一些问题,后面详细说明。
在确定直径时,我们做了一个隐含的假设,即每个链路的长度几乎相同,或者消息穿过链路所需的时间对于网络中的所有链路几乎相同,由此根据消息经过的链接数来定义直径。这种假设并不十分不切实际,因为与沿途路由器的延迟相比,通常通过链路的传播时间很短。然而,链路的延迟是有限制的,如果链接很长,对直径的定义需要修改,就圆环来说,有这样的情况。交叉链路在物理上比相邻节点之间的常规链路长√NN倍。因此,与网格相比,我实践中没有显著减小直径,因为一行末端的节点仍然相距很远。
幸运的是,可以通过使用一种稍加修改的称为折叠圆环(Folded Torus)的结构来解决这个问题,如下图所示。每一行和每一列的拓扑结构都像一个环,环的一半由原本是网格拓扑的一部分的规则链接组成,另一半由添加的交叉链接组成,这些交叉链接用于将网格转换为圆环,交替地将节点放置在常规链接和交叉链接上。该策略确保折叠环面中相邻节点之间的距离是规则环面中的相邻节点之间距离的两倍,但避免了行或列两端之间的长交叉链接(√NN跳长)。
网络的等分带宽和直径与圆环保持相同。在这种情况下,有几个路径可以作为最长路径,但从拐角到中心的路径不是最长的,最长的路径之一是在两个相对的角落之间。折叠圆环通常是多核处理器中的首选配置,因为它避免了长的交叉链路。
超立方体
现在考虑一个具有O(log(N))O(log(N))直径的网络,这些网络使用大量链接,因此它们不适用于多核,通常用于大型集群计算机。这个网络被称为超立方体(hypercube)。超立方体实际上是一个网络族,每个网络都有一个阶(order),k阶的超立方体称为HkHk。它有着下图所示的几种拓扑结构。
蝴蝶
最后一个叫做蝴蝶的网络,它也有O(log(N))O(log(N))的直径,但它适合于多核。下图显示了8个节点的蝶形网络,每个节点由一个圆表示。除了节点之外,还有一组交换机或内部节点(以矩形显示),用于在节点之间路由消息。消息从左侧开始,经过交换机,到达图表的右侧。请注意,图最左侧和最右侧的节点实际上是相同的节点集。没有添加从左到右的交叉链接,以避免使图表复杂化,图中显示了两个节点的集合。
拓扑结构对比
下表用四个参数:内部节点(或交换机)数量、链路数量、直径和二等分带宽来比较拓扑结构。在所有情况下,假设网络有N个节点可以发送和接收消息,N是2的幂。
拓扑 | 节点数 | 链接数 | 直径 | 等分网络 |
---|---|---|---|---|
链 | 0 | N−1N−1 | N−1N−1 | 1 |
环 | 0 | NN | N/2N/2 | 2 |
胖树 | N−1N−1 | 2N−22N−2 | 2log(N)2log(N) | N/2N/2 |
网格 | 0 | 2N−√N2N−N | 2√N−22N−2 | √NN |
圆环 | 0 | 2N2N | √NN | 2√N2N |
折叠圆环 | 0 | 2N2N | √NN | 2√N2N |
超立方体 | 0 | Nlog(N)/2Nlog(N)/2 | log(N)log(N) | N/2N/2 |
蝴蝶 | Nlog(N)/2Nlog(N)/2 | N+Nlog(N)N+Nlog(N) | log(N)+1log(N)+1 | N/2N/2 |
除此之外,还有以下类型的网络拓扑:
19.8 I/O和存储设备
计算机中需要I/O(输入/输出)系统,下图是典型计算机的结构。
典型的计算机系统。
I/O通道的两种架构。
处理器是计算机的核心,它连接到一系列I/O设备,用于处理用户输入和显示结果。这些I/O设备称为外围设备,最常见的用户输入设备是键盘和鼠标,而最常见的显示设备是监视器和打印机。计算机还可以通过一组通用I/O端口与许多其他设备进行通信,如相机、扫描仪、mp3播放器、摄像机、麦克风和扬声器。I/O端口包括:
- 一组金属引脚,用于帮助处理器与外部设备连接。
- 一个端口控制器,用于管理与外围设备的连接。计算机还可以通过一种称为网卡的特殊外围设备与外界通信,网卡包含通过有线或无线连接与其他计算机通信的电路。
I/O端口(I/O port)由一组金属引脚组成,用于连接外部设备提供的连接器。每个端口都与协调通信链路上数据交换的端口控制器相关联。
本章特别优先考虑一类特定的设备,即存储设备。硬盘和闪存驱动器等存储设备可以帮助我们永久地存储数据,即使系统断电后亦是如此。本章强调存储设备的原因是因为它们是计算机体系结构的组成部分,跨计算机的外围设备的性质各不相同。但是,从小型手持电话到大型服务器,所有计算机都有某种形式的永久存储,此存储用于在程序运行期间保存文件、系统配置数据和交换空间。因此,架构师特别关注存储系统的设计和优化。
19.8.1 I/O系统概述
现在远离I/O设备的确切细节,在设计计算机系统时,设计者不可能考虑所有可能类型的I/O设备,即使这样做,也有可能在电脑售出后出现一类新的设备,例如苹果iPad等平板电脑在2005年就不存在了。尽管如此,在iPad和旧电脑之间传输数据仍然是可能的,因为大多数设计师都在他们的计算机系统中提供标准接口,例如,典型的台式机或笔记本电脑有一组USB端口,任何符合USB规范的设备都可以连接到USB端口,然后可以与主机通信。类似地,几乎可以将任何监视器或投影仪与任何笔记本电脑相连,因为笔记本电脑有一个通用的DVI端口,可以连接到任何显示器。笔记本电脑公司通过实现DVI端口来遵守其DVI规范,DVI端口可以在处理器和端口之间无缝传输数据。在类似的线路上,监视器公司通过确保其监视器可以无缝显示DVI端口上发送的所有数据来遵守其的DVI规范部分。因此,我们需要确保计算机能够支持与外围设备的一组固定接口,且可以在运行时连接任何外围设备。
注意,仅仅因为可以通过实现端口的规范来连接通用I/O设备,并不意味着I/O设备可以工作。例如,可以始终将打印机连接到USB端口,但是打印机可能无法打印页面,原因是需要软件层面的额外支持来操作打印机。此支持内置于操作系统中的打印机设备驱动程序中,可以有效地将数据从用户程序传输到打印机。
因此需要明确区分软件和硬件的角色。先看软件,大多数操作系统都需要一个非常简单的用户界面来访问I/O设备,例如Linux操作系统有两个系统调用,即读和写,具体如下。
read(int file_descriptor, void *buffer, int num_bytes)
write(int file_descriptor, void *buffer, int num_bytes)
Linux将所有设备视为文件,并为它们分配一个文件描述符,文件描述符是第一个参数,指定了设备的id。第二个参数指向内存中包含数据源或目标的区域,最后一个参数表示需要传输的字节数。从用户的角度来看,这就是所有需要做的事情。这些正是操作系统的设备驱动程序的工作,以及协调其余过程的硬件,此法已被证明是访问I/O设备的一种非常通用的方法。
不幸的是,操作系统需要做更多的工作。对于每个I/O调用,它需要找到适当的设备驱动程序并传递请求,可能有多个进程试图访问同一I/O设备,在这种情况下,需要正确地调度不同的请求。
设备驱动程序的工作是与本地硬件接口并执行所需操作,通常使用汇编指令与硬件设备通信。它首先评估自己的状态,如果是空闲的,就要求外围设备执行所需的操作,启动存储系统和外围设备之间的数据传输过程。
下图概括了上述的讨论。图的上部显示了软件模块(应用程序、操作系统、设备驱动程序),图的下部显示了硬件模块。设备驱动程序使用I/O指令与处理器通信,然后处理器将命令路由到适当的I/O设备。当I/O设备有一些数据要发送给处理器时,它发送一个中断,然后中断服务例程读取数据,并将其传递给应用程序。
I/O系统(硬件和软件)。
实际上,整个I/O过程是一个极其复杂的过程,本章致力于研究和设计设备驱动程序,仅讨论I/O系统的硬件部分,并粗略地了解所需的软件支持。I/O系统的软件和硬件组件之间的重要差异点如下:
- I/O系统的软件组件由应用程序和操作系统组成。应用程序通常被提供一个非常简单的接口来访问I/O设备,操作系统的作用是整理来自不同应用程序的I/O请求,适当地调度它们,并将它们传递给相应的设备驱动程序。
- 设备驱动程序通过特殊的组装指令与硬件设备通信。它们协调处理器和I/O设备之间的数据、控制和状态信息的传输。
- 硬件(处理器和相关电路)的作用只是充当操作系统和连接到专用I/O端口的I/O设备之间的信使。例如,如果我们将数码相机连接到USB端口,则处理器不知道所连接设备的详细信息,它的唯一作用是确保相机的设备驱动程序和连接到相机的USB端口之间的无缝通信。
- I/O设备可以通过发送中断来启动与处理器的通信,会中断当前正在执行的程序,并调用中断服务例程。中断服务例程将控制传递给相应的设备驱动程序,然后设备驱动程序处理中断,并采取适当的操作。
下面将细讨论I/O系统的硬件组件的架构。
19.8.1.1 I/O系统要求
现在尝试设计I/O系统的架构,下表列出想要支持的所有设备及其带宽要求。需要最大带宽的组件是显示设备(监视器、投影仪、电视),它连接到图形卡,包含处理图像和视频数据的图形处理器。
设备 | 总线技术 | 带宽 | 典型值 |
---|---|---|---|
显示设备 | PCI Express(版本4) | 高 | 1-10 GB/s |
硬盘 | ATA/SCSI/SAS | 中 | 150-600 MB/s |
网卡(有线/无线) | PCI Express总线 | 中 | 10-100 MB/s |
USB设备 | USB(通用串口总线) | 中 | 60-625 MB/s |
DVD音频/视频 | PCI(个人计算机接口) | 中 | 1-4 MB/s |
扬声器/麦克风 | AC'97/Intel High. Def. Audio | 低 | 100 KB/s至3 MB/s |
键盘/鼠标 | USB、PCI | 非常低 | 10-100 B/s |
请注意,在讨论I/O设备时,经常使用术语卡(card)。卡是一块印刷电路板(PCB),可以连接到计算机的I/O系统以实现特定功能,例如,图形卡可以帮助我们处理图像和视频,声卡可以帮助处理高清晰度音频,网卡可以帮助连接到网络。网卡的图片如下图所示,可以看到印刷电路板上互连的一组芯片,有一组端口用于将外部设备连接到卡。
除了图形卡,另一个需要连接到CPU的高带宽设备是主内存,其带宽大约为10-20 GB/s。因此,我们需要设计一个对主内存和图形卡进行特殊处理的I/O系统。
其余设备的带宽相对较低,硬盘、USB设备和网卡的带宽要求限制在500-600 MB/s,键盘、鼠标、CD-DVD驱动器和音频外围设备的带宽要求极低(小于4 MB/s)。
19.8.1.2 I/O系统设计
结合上表,可以注意到有不同种类的总线技术,如USB、PCI Express和SATA,总线(bus)被定义为I/O系统中两个或两个以上元件之间的链路。我们使用不同类型的总线来连接不同类型的I/O设备,例如,使用USB总线连接USB设备(如笔驱动器和相机),使用SATA或SCSI总线连接到硬盘。需要使用这么多不同类型的总线的原因有:
- 不同I/O设备的带宽要求非常不同。图形卡需要使用非常高速的总线,而键盘和鼠标只需更简单的总线技术,因为总带宽需求最小。
- 历史原因。历史上,硬盘供应商一直使用SATA或IDE总线,而图形卡供应商一直使用AGP总线,2010年后,图形卡供应商转而使用PCI Express总线。
由于多种因素的组合,I/O系统设计者需要支持多种总线。
总线(bus)是一组用于并联连接多个设备的导线。设备可以使用总线在彼此之间传输数据和控制信号。
现在深入研究总线的结构。总线不仅仅是两个端点之间的一组铜线,实际上是一个非常复杂的结构,其规格通常长达数百页。我们需要关注它的电气特性、误差控制、发射机(transmitter)和接收机电路、速度、功率和带宽,本章将有充分的机会讨论高速总线。连接到总线的每个节点(源或目的地)都需要总线控制器来发送和接收数据,尽管总线的设计相当复杂,但我们可以将其抽象为一个逻辑链路,可以无缝可靠地将字节从单个源传输到一组目的地。
为了设计计算机的I/O系统,首先需要提供由一组金属引脚或插座组成的外部I/O端口,这些I/O端口可用于连接外部设备,每个端口都有一个与设备接口的专用端口控制器,然后端口控制器需要使用上表列出的总线之一将数据发送到CPU。
这里主要的设计问题是不可能通过I/O总线将CPU连接到每个I/O端口,有几个原因:
1、如果将CPU连接到每个I/O端口,那么CPU需要为每种总线类型配备总线控制器,会增加CPU的复杂性、面积和功耗。
2、CPU的输出引脚的数量是有限的。如果CPU连接到一个I/O设备主机,那么它需要大量额外的引脚来支持所有I/O总线,大多数CPU通常没有足够的引脚来支持此功能。
3、从商业角度来看,将CPU的设计与I/O系统的设计分开是一个好主意,这样就可以在各种各样的计算机中使用CPU。
因此,大多数处理器仅连接到一条总线,或最多连接到2到3条总线。我们需要使用辅助芯片将处理器连接到不同的I/O总线主机,它们需要聚合来自I/O设备的流量,并将CPU生成的数据正确路由到正确的I/O设备,反之亦然。这些额外的芯片包括给定处理器的芯片组,芯片组的芯片在被称为主板的印刷电路板上相互连接。
芯片组(Chipset):是主CPU连接到主内存、I/O设备和执行系统管理功能所需的一组芯片。
主板(Motherboard):芯片组中的所有芯片都在一块称为主板的印刷电路板上相互连接。
大多数处理器的芯片组中通常有两个重要的芯片:北桥(North Bridge)和南桥(South Bridge),如下图所示。CPU使用前端总线(FSB)连接到北桥芯片,北桥芯片连接到DRAM内存模块、图形卡和南桥芯片。相比之下,南桥芯片旨在处理速度慢得多的I/O设备,连接到所有USB设备,包括键盘和鼠标、音频设备、网卡和硬盘。
I/O系统架构。
为了完整起见,先阐述计算机系统中其他两种常见类型的总线:
-
后端总线(back side bus)。它用于将CPU连接到二级缓存。早期的处理器使用芯片外L2缓存,通过后端总线进行交流,如今的L2高速缓存已移动到芯片上,因此后端总线也在芯片上。它通常以核心频率计时,是一种非常快的总线。
-
背板总线(backplane bus)。它用于大型计算机或存储系统,通常具有多个主板和外围设备,如硬盘。所有这些实体都并联连接到单个背板总线,背板总线本身由多条平行铜线和一组可用于连接设备的连接器组成。
前端总线(front side bus):一种将CPU连接到内存控制器的总线,或者在Intel系统中连接到北桥芯片。
后端总线(back side bus):将CPU连接到二级缓存的总线。
背板总线(backplane bus):连接到多个主板、存储和外围设备的系统范围总线。
北桥和南桥芯片都需要为它们所连接的所有总线配备总线控制器,每个总线控制器协调对其相关总线的访问。成功接收数据包后,它将数据包发送到目的地(朝向CPU或I/O设备)。由于这些芯片互连各种类型的总线,并在目标总线繁忙时临时缓冲数据值,因此它们被称为桥(总线之间的桥)。
内存控制器是北桥芯片的一部分,实现对主存储器的读/写请求。在过去的几年里,处理器供应商已经开始将内存控制器转移到主CPU芯片中,并使其更加复杂,对内存控制器的大多数增强都集中在降低主内存功率、减少刷新周期数和优化性能上。从Intel Sandybridge处理器开始,图形处理器也移动到芯片上。把它们放入CPU芯片的原因是:
- 有额外的晶体管可用。
- 芯片内通信比芯片外通信快得多。许多嵌入式处理器还将南桥芯片的大部分、端口控制器以及CPU集成在一个芯片中,有助于减小主板的尺寸,并允许I/O控制器和CPU之间更有效的通信。这种类型的系统被称为SOC(System on Chip,片上系统)。
SOC(System on Chip,片上系统)通常将计算系统的所有相关部分封装到单个芯片中,包括主处理器和I/O系统中的大部分芯片。
19.8.1.3 I/O系统中的层
大多数复杂的架构通常被分为多个层(layer),犹如互联网架构,一层基本上独立于另一层。因此,我们可以选择以任何方式实现它,只要它符合标准接口。现代计算机的I/O架构也相当复杂,有必要将其功能划分为不同的层。
我们可以将I/O系统的功能大致分为四个不同的层。请注意,我们将I/O系统的功能划分为多个层,主要是受7层OSI模型(用于划分广域网的功能层)的启发:
-
物理层:总线的物理层主要定义总线的电气规格。它分为两个子层,即传输子层(transmission sublayer)和同步子层(synchronisation sublayer)。
传输子层定义了传输比特的规范。例如,一条总线可以为高电平有效(如果电压为高电平,则逻辑1),另一条总线可为低电平有效(电压为零电平,则为逻辑1)。今天的高速总线使用高速差分信号,可以使用两根铜线来传输单个比特,通过监测两条导线之间电压差的符号来推断逻辑0或1(类似于SRAM单元中位线的概念)。现代总线扩展了这一思想,并使用电信号组合对逻辑位进行编码。
同步子层规定了信号的定时,以及恢复接收器在总线上发送的数据的方法。
-
数据链路层:数据链路层主要用于处理物理层读取的逻辑位,将比特集分组为帧,执行错误检查,控制对总线的访问,并帮助实现I/O事务。具体而言,它确保在任何时间点只有一个实体可以在总线上传输信号,并且实现了利用公共消息模式的特殊功能。
-
网络层:该层主要涉及通过芯片组中的各种芯片将一组帧从处理器成功传输到I/O设备,反之亦然。我们唯一地定义了I/O设备的地址,并考虑了在I/O指令中嵌入I/O设备地址的方法。大体上讨论两种方法:基于I/O端口的寻址和内存映射寻址,在后一种情况下,将对I/O设备的访问视为对指定内存位置的常规访问。
-
协议层:最顶层称为协议层,负责端到端执行I/O请求,包括处理器和I/O设备之间在消息语义方面进行高级通信的方法。例如,I/O设备可以中断处理器,或者处理器可以明确请求每个I/O设备的状态。其次,为了在处理器和设备之间传输数据,可以直接传输数据,也可以将数据传输的责任委托给称为DMA控制器的芯片组中的专用芯片。
下图总结了典型处理器的4层I/O架构。
I/O系统中的4个层。
19.8.2 物理层:传输子层
物理层是I/O系统的最下层,涉及源和接收器之间信号的物理传输。它又可以被分成两个子层,第一个子层是传输子层,它处理从源到目的地的比特传输,该子层涉及链路的电特性(电压、电阻、电容),以及使用电信号表示逻辑位(0或1)的方法。
第二个子层称为同步子层,涉及从物理链路读取整个比特帧,其中帧被定义为一组由特殊标记划分的比特。由于I/O通道受到抖动(不可预测的信号传播时间)的困扰,因此有必要正确地同步数据到达接收器,并正确读取每一帧。
本节将讨论传输子层,下一节讨论同步子层。
请注意,创建多个子层而不是创建多个层的原因是因为子层不需要彼此独立。理论上,可以使用任何物理层和任何其他数据链路层协议,理想情况下,它们应该完全忘记对方。但是,传输子层和同步子层具有很强的联系,因此不可能将它们分离成单独的层。
下图显示了I/O链路的一般视图。源(发射机)向目的地(接收机)发送一系列比特,在传输时,数据始终与源的时钟同步,意味着,如果源以1GHz运行,那么它以1GHZ的速率发送比特。请注意,源的频率不一定等于发送数据的处理器或I/O元件的频率。传输电路通常是一个单独的子模块,它有一个时钟,该时钟是从作为其一部分的模块的时钟中导出的,例如,处理器的传输电路可能以500MHz传输数据,而处理器可能以4GHz运行。在任何情况下,我们假设发射机以其内部时钟速率传输数据,该时钟速率也称为总线频率(bus frequency),该频率通常低于处理器或芯片组中其他芯片的时钟频率。接收器可以以相同的频率运行,也可以使用更快的频率,除非明确说明,否则不会假设源和目的地具有相同的频率。最后要注意,我们将互换使用发送器、源和发送器这三个术语,同样将互换使用“目标”和“接收者”这两个术语。
I/O链路的通用视图。
19.8.2.1 单端信号
现在考虑一种简单的方法,通过从源向目的地发送脉冲序列来发送1和0的序列,这种信令方法称为单端信号(single ended signalling),是最简单的方法。
在特定情况下,可以将高电压脉冲与1相关联,将低电压脉冲与0相关联,这种约定被称为高电平有效(active high)。或者,可以将低压脉冲与逻辑1相关联,将高压脉冲与逻辑0相关联,相反,这种约定被称为低电平有效(active low)。这两种约定如下图所示。
高电平有效和低电平有效的信号发送方法。
可悲的是,这两种方法都极其缓慢和过时。回顾之前章节对SRAM单元的讨论,快速I/O总线需要将逻辑0和1之间的电压差降低到尽可能低的值,因为电压差在对具有内部电容的检测器充电之后被检测到。所需电压越高,电容器充电所需时间越长。如果电压差为1伏,则需要很长时间才能检测到从0到1的转换,将限制总线的速度。然而,如果电压差为30mV,就可以更快地检测到电压的转变,从而可以提高总线的速度。
因此,现代总线技术试图将逻辑0和1之间的电压差降至尽可能低的值。请注意,为了提高总线速度,不能任意减小逻辑0和1之间的电压差。例如,不能让所需的电压差为0.001 mV,因为系统中存在一定量的电噪声,是由几个因素引起的。如果手机在汽车或电脑的扬声器打开时开始响起,那么扬声器中也会有一定的噪音。如果把一部手机放在微波炉旁边,而它正在运行,那么手机的音质就会下降,因为有电磁干扰。同样,处理器中也可能存在电磁干扰,并可能引入电压尖峰。假设这种电压尖峰的最大振幅为20mV,那么0和1之间的电压差需要大于20mV。否则,由于干扰引起的电压尖峰可能会翻转信号的值,从而导致错误。下一节简单地阐述一下片上信令最常见的技术之一,即LVDS。
19.8.2.2 低压差分信号(LVDS)
LVDS使用两根导线传输单个信号。监测这些导线的电压差。从电压差的符号推断出传递的值。
基本LVDS电路如下图所示。有一个3.5 mA的固定电流源。根据输入a的值,电流通过线路1或线路2流向目的地。例如,如果a为1,则电流流经线路1,因为晶体管T1开始导通,而T2截止。在这种情况下,电流到达目的地,通过电阻器Rd,然后通过线路2流回。通常,当没有电流流动时,两条线路的电压保持在1.2V。当电流流过时,存在电压摆动。电压摆动等于3.5mA乘以Rd。Rd通常为100欧姆。因此,总差分电压摆动为350 mV。检测器的作用是检测电压差的符号。如果是肯定的,它可以声明逻辑1。否则,它可以宣布逻辑0。由于摆动电压低(350 mV),LVDS是一种非常快速的物理层协议。
LVDS电路。
19.8.2.3 多位传输
现在考虑按顺序发送多个比特的问题。大多数I/O通道不是一直都很忙,只有在传输数据时才忙,因此它们的占空比(设备运行时间的百分比)往往是高度可变的,而且大多数时间都不是很高。然而,检测器几乎一直处于开启状态,一直在检测总线的电压,可能会影响功耗和正确性。功率是一个问题,因为检测器在每个周期都会检测到逻辑1或0,更高级别的层有必要处理数据。为了避免这种情况,大多数系统通常都有一条额外的线来指示数据位是有效还是无效,这条线路传统上被称为闪控(strobe)。发送器可以通过设置闪控的值来向接收器指示数据的有效期,同样地,有必要同步数据线和闪控。对于高速I/O总线来说,变得越来越困难,因为数据线上的信号和闪控可能会受到不同延迟量的影响。因此,这两条线路可能会不同步,最好定义三种类型的信号:0、1和空闲,其中0和1表示总线上逻辑0和1的传输,而空闲状态是指没有信号被发送的事实。这种信令模式也称为三元信令(ternary signalling),因为使用了三种状态。
我们可以使用LVDS轻松实现三元信令。不妨将LVDS中的导线分别称为A和B,VA是A线的电压,VB是B线的电压。分为以下几种情况:
- 如果|VA−VB|<τ|VA−VB|<τ,其中ττ是检测阈值,就推断线路是空闲的,没有传输任何内容。
- 如果|VA−VB|>τ|VA−VB|>τ,表示正在传输逻辑1。
- 如果|VB−VA|>τ|VB−VA|>τ,表示正在传输逻辑0。因此,不需要对基本LVDS协议进行任何更改。
后面阐述一组优化用于在物理层中传输多个比特的技术。
19.8.2.4 归零(RZ)协议
此协议会发送一个脉冲(正或负),然后在一个比特周期内暂停一段时间。此处可将比特周期定义为传输比特所需的时间,大多数I/O协议假设比特周期独立于正在传输的比特(0或1)的值,通常,1位周期等于一个I/O时钟周期的长度,I/O时钟是I/O系统元件使用的专用时钟。我们将互换使用术语时钟周期(clock cycle)和位周期(bit period),不强调术语之间的差异。
在RZ协议中,如果希望发送逻辑1,就在链路上发送一个正电压脉冲,持续一个比特周期的一小部分,随后停止发送脉冲,并确保链路上的电压恢复到空闲状态。类似地,当传输逻辑0时,沿着线路发送一个负电压脉冲,持续一个周期的一小部分,随后等待直到线路返回空闲状态。这可通过允许电容器放电,或通过施加反向电压使线路进入空闲状态来实现。在任何情况下,关键点是,当在传输时,传输比特周期的某一部分的实际值,然后允许线路返回到默认状态,不妨假设为空闲状态。返回到空闲状态有助于接收器电路与发送器的时钟同步,从而正确读取数据,这里隐含的假设是,发送方每个周期(发送方周期)发送一个比特。注意,发送器和接收器的时钟周期可能不同。
下图显示了带有三元信令的RZ协议示例。如果要使用二进制信令,就可以有如下的替代方案:
- 对于逻辑1,我们可以在一个周期内发送一个短脉冲。
- 对于逻辑0,则不发送任何信号。
主要问题是,通过查看发送逻辑1后的暂停长度,来判断是否正在发送逻辑0,需要在接收器末端设置复杂的电路。
归零(RZ)协议(示例)。
然而,RZ(归零)方法的主要缺点是浪费带宽,需要在传输逻辑0或1之后引入一个短暂的暂停(空闲期)。事实证明,我们可以设计不受此限制的协议。
19.8.2.5 曼彻斯特编码
在讨论曼彻斯特编码之前,让我们区分物理位(physical bit)和逻辑位(logical bit)。到目前为止,我们一直认为它们的意思是一样的,然而从现在起,将不再如此。物理位(如物理1或0)表示链路两端的电压,例如,在有效高电平信令方法中,高电压指示正在传输位1,而低电压(物理位0)指示正在发送0位。然而,现在情况不再如此,因为我们假设逻辑位(逻辑0或1)是物理位值的函数,比如当前和前一个物理位等于10,可以推断逻辑0,同样,可以有不同的规则来推断逻辑1。接收器的工作是将物理信号(或者更确切地说是物理位)转换为逻辑位,并将其传递到I/O系统的更高层。下一层(数据链路层)接受来自物理层的逻辑位,它忽略了信令的性质,以及链路上传输的物理比特的含义。
现在讨论曼彻斯特编码(Manchester Encoding)的机制。这里将逻辑位编码为物理位的转换,下图显示了一个示例。物理位的0→10→1转换编码逻辑1,相反,物理位的1→01→0转换编码逻辑0。
曼彻斯特代码(示例)。
曼彻斯特码总是有一个转换来编码数据。大多数时候,在一段时间的中间,有一个转换。如果没有转换,可以得出结论,没有信号被传输,链路是空闲的。曼彻斯特编码的一个优点是很容易解码在链路上发送的信息,只需要检测转换的性质,另外,不需要外部闪控信号来同步数据。数据被称为是自计时的(self clocked),意味着可以从数据中提取发送方的时钟,并确保接收方以发送方发送数据的相同速度读取数据。
曼彻斯特编码用于IEEE 802.3通信协议,该协议构成了今天局域网以太网协议的基础。批评者认为,由于每一个逻辑位都与一个转换相关联,我们最终不必要地消耗了大量的能量。每一次转换都需要我们对与链路、驱动器和相关电路相关的一组电容器进行充电/放电。相关的电阻损失作为热量消散,因此让我们尝试减少转换次数。
19.8.2.6 不归零(NRZ)协议
此方法利用了1和0的运行。对于传输逻辑1,将链路的电压设置为高。类似地,对于传输逻辑0,将链路的电压设置为低。现在考虑两个1位的运行。对于第二位,不会在链路中引起任何跃迁,并且将链路的电压保持为高。类似地,如果有n个0。然后,对于最后的(n-1)0,保持链路的低电压,因此没有跃迁。
下图显示了一个示例,我们观察到,当需要传输的逻辑位的值保持不变时,通过完全避免电压转换,已经最小化了转换次数。该协议速度快,因为没有浪费任何时间(例如RZ协议),并且功率效率高,因为消除了相同位运行的转换(与RZ和曼彻斯特码不同)。
不归零协议(示例)。
然而,增加的速度和功率效率是以复杂性为代价的。假设要传输一个100个1的字符串,在这种情况下,只对第一位和最后一位进行转换。由于接收方没有发送方的时钟,因此无法知道比特周期的长度。即使发送器和接收器共享相同的时钟,由于链路中引起的延迟,接收器可能会得出结论,有99或101位的运行,概率为零。因此,必须发送额外的同步信息,以便接收器能够正确读取在链路上发送的所有数据。
不归零(NRZI)反转协议
不归零(NRZI)反转协议是NRZ协议的变体。当希望编码逻辑1时,有一个从0到1或1到0的转换,而对于逻辑0,没有转换。下图显示了一个示例。
19.8.3 物理层:同步子层
传输子层确保脉冲序列从发射机成功地发送到一个接收机或一组接收机。然而还不够,接收机需要在正确的时间读取信号,并且需要假定正确的比特周期。它如果读取信号太早或太晚,就有可能获得错误的信号值。其次,如果它假设了错误的比特周期值,那么NRZ协议可能不起作用,因此,需要保持源和目的地之间的时间概念。目标需要确切地知道何时将值传输到锁存器中。让我们考虑针对单一来源和目的地的解决方案。
总之,同步子层从传输子层接收逻辑比特序列,而没有任何定时保证。它需要计算出比特周期的值,并读入发送方发送的整个数据帧(固定大小的块),然后将其发送到数据链路层。请注意,找出帧边界和在帧中放置比特集的实际工作是由数据链路层完成的。
19.8.3.1 同步总线
首先考虑同步系统的情况,其中发送方和接收方共享相同的时钟,并且将数据从发送方传输到接收方只需一个周期的一小部分,此外,假设发送方一直在发送。让我们把这个系统称为一个简单的同步总线(synchronous bus)。
在这种情况下,发送方和接收方之间的同步任务相当简单。我们知道数据是在时钟的负边缘发送的,在不到一个周期的时间内就到达了接收器,需要避免的最重要的问题是亚稳态。当数据在时钟负边缘附近的一个小时间窗口内发生转变时,触发器进入亚稳态。具体而言,我们希望数据在时钟边缘之前的设置时间间隔内保持稳定,而数据需要在时钟边缘之后的保持时间间隔内稳定。由设置和保持间隔组成的间隔被称为时钟的禁止区域(keep-out region)。
在这种情况下,假设数据在少于tclk−tsetuptclk−tsetup时间单位的时间内到达接收器,因此不存在亚稳态问题,我们可以将数据读取到接收器的触发器中。由于数字电路通常以较大的块(字节或字)处理数据,在接收器处使用串入——并行地从寄存器出,串行读入n位,并一次性读出n位块。由于发送器和接收器时钟相同,因此没有速率不匹配。接收器的电路如下图所示。
简单同步总线的接收器。
在中时系统中,信号和时钟之间的相位差是一个常数。由于链路中的传播延迟以及发送器和接收器的时钟中可能存在相位差,所以可以在信号中引入相位差。在这种情况下,我们可能会出现亚稳态问题,因为数据可能会到达接收器时钟的关键禁区,因此需要添加一个延迟元件,该延迟元件可以将信号延迟一个固定的时间量,使得在接收器时钟的禁止区域中没有转变。电路的其余部分与用于简单同步总线的电路保持相同。电路设计如下图所示。
中型总线的接收器。
延迟元件可以通过使用延迟锁定环(DLL)来构造,DLL可以有不同的设计,其中一些设计可能相当复杂,一个简单的DLL由一系列反相器组成。注意,我们需要有偶数个反相器,以确保输出等于输入。为了创建一个可调延迟元件,可以在每对反相器之后抽头信号。这些信号在逻辑上等同于输入,但由于反相器的传播延迟而具有渐进相位延迟,然后可以使用多路复用器选择具有特定相位延迟量的信号。
现在考虑一个更现实的情景。在这种情况下,发送方和接收方的时钟可能不完全相同,可能有少量的时钟漂移(drift),可以假设在几十或几百个周期内,它是最小的,然而可以在数百万个周期中有几个周期的漂移。第二,假设发送方不总是传输数据,总线中有空闲时间。这种总线在服务器计算机中可以找到,在服务器计算机上,有多个主板,理论上以相同的频率运行,但不共享一个公共时钟。当考虑数百万周期量级的时间尺度时,处理器之间存在一定的时钟漂移。
现在做一些简单的假设。通常,给定的数据帧包含100或可能1000位。当传输几位(<100)时,不必担心时钟漂移。然而,对于更多的比特(>100),需要周期性地重新同步时钟,以便不会丢失数据。此外,确保接收器时钟的禁止区域中没有跃迁是一个非常重要的问题。
为了解决这个问题,我们使用了一个称为闪控的附加信号,该信号与发送器的时钟同步。在帧传输开始时(或者可能在发送第一个数据比特之前的几个周期)触发闪控脉冲,然后每n个周期周期性地切换闪控脉冲一次。在这种情况下,接收机使用可调谐延迟元件,它根据接收闪控脉冲的时间和时钟转换之间的间隔来调整其延迟。发送闪控脉冲几个周期后,开始传输数据。由于时钟会漂移,需要重新调整或重新调整延迟元件,所以有必要周期性地向接收机发送闪控脉冲。下图显示了数据和闪控的时序图。
准同步总线的时序图。
与中型总线的情况类似,每n个周期,接收机可以使用串行输入——并行输出寄存器并行读出所有n位。接收器的电路如下图所示。我们有一个延迟计算器电路,将闪控脉冲和接收器时钟(rclk)作为输入。基于相位延迟,它调谐延迟元件,使得来自源的数据到达接收机时钟周期的中间。由于以下原因,需要这样做:由于发送方和接收方时钟周期不完全相同,因此可能存在速率不匹配的问题。我们可能在一个接收机时钟周期内得到两个有效数据位,或者根本没有得到位。当一位到达时钟周期的开始或结束时,就会发生这种情况。因此,我们希望确保比特在时钟周期的中间到达,此外,还存在亚稳态避免问题。
准同步总线的接收器。
不幸的是,相位会逐渐改变,比特可能会在时钟周期开始时到达接收器,然后可以在同一周期中接收两个比特。在这种情况下,专用电路需要预测该事件,并预先向发送方发送消息以暂停发送比特。同时,延迟元件应该被重新调谐,以确保比特到达周期的中间。
19.8.3.2 源同步总线
可悲的是,即使是准同步总线也很难制造。在传输信号时,经常会有很大且不可预测的延迟,甚至很难确保紧密的时钟同步。例如,用于在同一主板上的不同处理器之间提供快速I/O路径的AMD超传输协议不采用同步或准同步时钟。其次,该协议假设了高达1个周期的额外抖动(信号传播时间的不可预测性)。
在这种情况下,需要使用更复杂的闪控信号。在源同步总线中,通常将发送器时钟作为选通信号发送,如果在信号传播时间中引入延迟,那么信号和闪控脉冲将受到同等影响。这是一个非常现实的假设,截至2013年,大多数高性能I/O总线都使用源同步总线。源同步总线的电路也不是很复杂,我们使用发送器的时钟(作为闪控信号发送)将数据输入串行输入——并行输出寄存器,它被称为xclk。我们使用接收器的时钟读取数据,如下图所示。通常,每当信号跨越时钟边界时,都需要一个可调谐的延迟元件来将跃迁保持在禁止区域之外。因此有一个延迟计算器电路,它根据作为闪控脉冲接收的发送器时钟(xclk)和接收器时钟(rclk)之间的相位差来计算延迟元件的参数。
源同步总线的接收器。
注意,可以具有多个并行数据链路,从而可以同时发送一组比特,所有数据线可以共享携带同步时钟信号的闪控脉冲。
19.8.3.3 异步总线
现在考虑最通用的总线类,即异步总线。在此,不保证发送方和接收方的时钟同步,也不会将发送器的时钟与信号一起发送。接收器的工作是从信号中提取发送器的时钟,并正确读取数据。让我们看看下图所示的数据读取电路。
异步总线中的接收器电路。
为了便于解释,假设使用NRZ编码位的方法,将设计扩展到其他类型的编码相当容易。由传输子层传递的逻辑比特流被发送到第一D触发器,同时被发送到时钟检测器和恢复电路,这些电路检查I/O信号中的转变,并尝试猜测发送器的时钟。具体而言,时钟恢复电路包含PLL(锁相环),PLL是一种振荡器,它生成时钟信号,并试图调整其相位和频率,使其尽可能接近输入信号中的转变序列。请注意,这是一个相当复杂的操作。
在RZ或曼彻斯特编码的情况下,有周期性跃迁,因此更容易在接收机处同步PLL电路。然而,对于NRZ编码,没有周期性跃迁,因此接收机处的PLL电路可能会失去同步。许多使用NRZ编码的协议(特别是USB协议)在信号中插入周期性转换或伪比特,以使接收器处的PLL重新同步。其次,时钟恢复电路中的PLL还需要处理总线中长时间不活动的问题,在此期间,它可能会失去同步。有先进的方案可以确保从异步信号中正确恢复时钟,本节只粗略地阐述,并假设时钟恢复电路正确地完成其工作。
我们将时钟检测和恢复电路的输出连接到第一个D触发器的时钟输入,因此根据发送者的时钟对数据进行计时。为了避免亚稳态问题,在两个D触发器之间引入了延迟元件,第二个D触发器在接收器的时钟域中。这部分电路与源同步总线的电路相似。
注意,在三元信令的情况下,很容易发现总线何时处于活动状态(当在总线上看到物理0或1时)。然而在二进制信令的情况下,不知道总线何时处于活动状态,因为原则上一直在传输0或1位,因此有必要使用附加闪控信号来指示数据的可用性。现在来看看使用闪控信号来指示总线上数据可用性的协议,闪控信号也可以可选地由三元总线用于指示I/O请求的开始和结束。在任何情况下,使用闪控信号提出的两种方法都是相当基本的,已经被更先进的方法所取代。
假设源希望向目标发送数据。它首先将数据放置在总线上,在一个小的延迟后设置(设置为1)闪控,如下图中的时序图所示。这样做是为了确保在接收器感知到要设置的选通之前,数据在总线上是稳定的。接收器立即开始读取数据值,直到闪控开启,接收器继续读取数据,将其放入寄存器,并将数据块传输到更高层。当源决定停止发送数据时,它重置(设置为0)闪控。注意,这里的时机很重要,通常在停止发送数据之前重置闪控。需要等待完成,因为希望接收器在选通复位后将总线内容视为最后一位。一般而言,希望数据信号在读取后保持其值一段时间(对于亚稳态约束)。
基于闪控的异步通信系统的时序图。
注意,在使用闪控信号的简单异步通信中,源无法知道接收器是否读取了数据。因此,引入了一种握手协议,其中源明确地知道接收器已经读取了其所有数据。相关的时序图如图下图所示。
基于闪控的异步通信系统的时序图。
一开始,发送方将数据放在总线上,然后设置闪控。接收器一观察到要设置的闪控脉冲,就开始从总线上读取数据。读取数据后,它将ack线设置为1。在发射机观察到ack线设置设置为1后,可以确定接收机已读取数据。因此,发射机重置闪控脉冲,并停止发送数据。当接收机观察到闪控脉冲已复位时,它将复位确认线。随后,发射机准备使用相同的步骤序列再次发射。
这一系列步骤确保发射器知道接收器已读取数据的事实。注意,当接收机能够确定它已经读取了发射机希望发送的所有数据时,该图是有意义的,因此设计者大多使用该协议来传输单个比特。在这种情况下,在接收器读取位之后,它可以断言ack线。其次,该方法也与RZ和曼彻斯特编码方法更相关,因为发射机需要在发送新比特之前返回到默认状态。收到确认后,发射机可以开始返回默认状态的过程,如上上图所示。
为了并行传输多个比特,需要为每条数据线设置一个闪控脉冲,然而可以有一条共同的确认线,需要在所有接收器都已读取其位时设置ack信号,并且需要在所有闪控线都已重置时重置ack线。最后,该协议中有四个独立的事件(如图所示)。因此,该协议被称为4阶段握手协议(4-phase handshake protocol)。
如果使用NRZ协议,就不需要返回默认状态,可以在收到确认后立即开始发送下一个比特。然而,在这种情况下,需要稍微改变闪控和确认信号的语义。下图显示了时序图。
具有2相握手的基于闪控的异步通信系统的时序图。
在这种情况下,在将数据放在总线上之后,发射机切换闪控脉冲的值。随后,在读取数据后,接收器切换确认行的值。发射机检测到确认线已切换后,开始发送下一位。短时间后,它切换闪控脉冲的值以指示数据的存在。再次,在读取位之后,接收器切换ack线,因此协议继续。注意,在这种情况下,我们不是设置和重置ack和闪控线,而是切换它们,以减少需要在总线上跟踪的事件数量,然而,需要在发送方和接收方保持一些额外的状态。这是微不足道的开销,因此,4相协议得到了显著简化。NRZ协议更适合这种方法,因为它们具有连续的数据传输,没有任何中间的暂停周期。
简单同步总线:一种简单的同步总线,假设发射机和接收机共享相同的时钟,并且时钟之间没有偏差。
中时总线(Mesochronous Bus):发射器和接收器具有相同的时钟频率,但时钟之间可能存在相位延迟。
准同步总线(Plesiochronous Bus):发射器和接收器的时钟频率之间存在少量不匹配。
源同步总线(Source Synchronous Bus):发射器和接收器的时钟之间没有关系。因此将发射机的时钟与消息一起发送给接收机,以便它可以使用它来对消息中的比特进行采样。
异步总线(Asynchronous Bus):异步总线不假定发射机和接收机的时钟之间有任何关系,通常具有复杂的电路,通过分析消息中的电压转变来恢复发射机的时钟。
19.8.4 数据链路层
数据链路层从物理层获取逻辑位序列。如果串行输入-并行输出寄存器的宽度是n位,那么保证一次获得n位。数据链路层的工作是将数据分成帧,并缓冲帧,以便在其他传出链路上传输。其次,它执行基本的错误检查和纠正。由于电磁干扰,可能会在信号中引起误差,例如,逻辑1可能会翻转为逻辑0,反之亦然。可以在数据链路层中纠正这种单比特错误,如果存在大量错误,并且不可能纠正错误,那么在这个阶段,接收机可以向发射机发送请求重传的消息。错误检查后,如果需要,可以在另一条链路上转发该帧。
可能有多个发送者同时试图访问一条总线。在这种情况下,需要在请求之间进行仲裁,并确保在任何一个时间点只有一个发送方可以发送数据。此过程称为仲裁(arbitration),通常也在数据链路层中执行。最后,仲裁逻辑需要对处理作为事务一部分的请求提供特殊支持,比如到内存单元的总线可能包含作为内存事务一部分的加载请求。作为响应,内存单元发送包含内存位置的内容的响应消息。我们需要在总线控制器级别提供一些额外的支持,以支持这样的消息模式。
概括而言,数据链路层将从物理层接收到的数据分解为帧,执行错误检查,通过允许在单个时间使用单个发射机来管理总线,并优化常见消息模式的通信。
19.8.4.1 分帧和缓存
数据链路层中的处理开始于从物理层读取比特集,可以有一个串行链路,也可以有同时传输比特的多个串行链路。一组多个串行链路称为并行链路。在这两种情况下,我们读入数据,将它们串行保存在——并行输出移位寄存器,并将比特块发送到数据链路层,数据链路层的作用是根据从物理层获得的值创建比特帧。对于从键盘和鼠标传输数据的链路,帧可能是一个字节,对于在处理器和主内存或主内存和图形卡之间传输数据的链路,帧可能高达128个字节。在任何情况下,每个总线控制器的数据链路层都知道帧大小,主要问题是划定帧的边界。帧划分方法有:
- 通过插入长暂停进行划分。在两个连续帧之间,总线控制器可以插入长暂停。通过检查这些暂停的持续时间,接收机可以推断帧边界。但是,由于I/O通道中的抖动,这些暂停的持续时间可能会发生变化,并且可能会引入新的暂停。此法不是一种非常可靠的方法,而且还浪费宝贵的带宽。
- 比特计数。可以先验地乘以一帧中的比特数,简单地计算发送的比特数,并在所需的比特数到达接收器后宣布一帧结束。主要问题是,有时由于信号失真,脉冲会被删除,并且很容易失去同步。
- 位/字节填充。是最灵活的方法,用于大多数商业I/O总线实现。此法使用预先指定的比特序列来指定帧的开始和结束,例如,我以使用模式0xDEADBEF指示帧的开始,使用0x12345678指示帧的结束。帧中任何32位序列在开始和结束时与特殊序列匹配的概率很小,概率等于2−322−32或2:5e−102:5e−10。不幸的是,概率仍然是非零,因此可以采用一个简单的解决方案来解决这个问题。如果序列0xDEADBEF出现在帧的内容中,就再添加32个虚位并重复此模式,比如将位模式0xDEADBEF替换为0xDEADEEFDEADBEF。接收机的链路层可以发现该模式重复偶数次。模式中的一半位是帧的一部分,其余的是伪位,然后接收机可以继续去除伪比特。这种方法是灵活的,它可以对抖动和可靠性问题非常有弹性。这些序列也称为逗点(commas)。
一旦数据链路层创建了一个帧,它就将其发送到错误检查模块,并对其进行缓冲。
19.8.4.2 检错和纠错
由于各种原因,在信号传输中可能引入误差。由于附近运行的其他电子设备,可能会受到外部电磁干扰,比如打开微波炉等电子设备后,可能会注意到手机的音质下降,因为电磁波耦合到I/O通道的铜线并引入电流脉冲。还可能受到附近电线的额外干扰(称为串扰),以及电线传输延迟因温度而发生的变化。累积起来,干扰会引起抖动(在信号的传播时间中引入变化),并引入失真(改变脉冲的形状)。因此,可能错误地将0解释为1,反之亦然。因此,有必要添加冗余信息,以便能够恢复正确的值。
注意,在实践中出错的概率很低,主板上互连的传输率通常不到百万分之一,但也不是一个很小的数字。如果每秒有一百万次I/O操作,通常每秒就会有一次错误,实际上是一个非常高的错误率。因此需要向位添加额外的信息,以便检测错误并从错误中恢复。这种方法被称为前向纠错(forward error correction)。相比之下,在反向纠错(backward error correction)中,我们检测错误,丢弃消息,并请求发送方重新发送。下面讨论流行的错误检测和恢复方案。
单个错误检测
由于单比特错误是相当不可能的,因此在同一帧中出现两个错误的可能性极低。因此,让我们专注于检测单个错误,并假设只有一位由于错误而翻转其状态。
让我们简化问题。假设一帧包含8位,我们希望检测是否存在单位错误。让我们将帧中的比特编号为D1;D2;::;D8。现在让我们添加一个称为奇偶校验位的附加位。奇偶校验位P等于:
P=D1⊕D2⊕…⊕D8P=D1⊕D2⊕…⊕D8
这里,⊕⊕操作是XOR运算符,简而言之,奇偶校验位表示所有数据位(D1 ... D8)的XOR。对于每8位,我们发送一个额外的位,即奇偶校验位(parity bit),因此将8位消息转换为等效的9位消息。在这种情况下,以更高的可靠性为代价,有效地增加了可用带宽12.5%的开销。下图显示了使用8位奇偶校验方案的帧或消息的结构。注意,还可以通过将单独的奇偶校验位与8个数据位的每个序列相关联来支持更大的帧大小。
带有奇偶校验位的8位消息。
当接收器接收到消息时,它通过计算8个数据位的XOR来计算奇偶性。如果该值与奇偶校验位匹配,就可以断定没有错误,但如果消息中的奇偶校验位与计算出的奇偶校验比特的值不匹配,就可以得出结论,存在单个比特错误。错误可能出现在消息中的任何数据位中,甚至可能出现在奇偶校验位中。在这种情况下,无从得知,所能检测到的只是存在一个位错误。现在尝试纠正错误。
单个错误校正
要纠正单个位错误,如果有错误,需要知道已丢弃的位的索引,现在统计一下可能的结果。对于n位块,需要知道有错误的位的索引,此种情况下,可以有n个可能的索引,没有错误,因此对于单个纠错(SEC)电路,总共有n+1个可能结果(n个结果有错误,一个结果无错误)。因此从理论角度来看,需要[log(n+1)][log(n+1)]个额外的比特,比如对于8位帧,需要[log(8+1)]=4[log(8+1)]=4位。让我们设计一个(8,4)代码,每8位数据字有四个附加位。
让我们从扩展奇偶校验方案开始。假设四个附加比特中的每一个都是奇偶校验比特,但它们不是整个数据位集合的奇偶校验函数,相反,每个比特是数据比特子集的奇偶校验。四个奇偶校验位分别命名为P1、P2、P3和P4,此外,排列8个数据位和4个奇偶校验比特,如下图所示。
数据和奇偶校验位的排列。
将奇偶校验位P1、P2、P3和P4分别保持在位置1、2、4和8,将数据位D1...D8分别排列在位置3、5、6、7、9、10、11和12,下一步是为每个奇偶校验位分配一组数据位。用二进制表示每个数据位的位置,在这种情况下,需要4个二进制位,因为需要表示的最大数字是12。现在将第一个奇偶校验位P1与其位置(以二进制表示)的LSB为1的所有数据位相关联,在这种情况下,以1作为LSB的数据位是D1(3)、D2(5)、D4(7)、D5(9)和D7(11)。因此,将奇偶校验位P1计算为:
P1=D1⊕D2⊕D4⊕D5⊕D7P1=D1⊕D2⊕D4⊕D5⊕D7
类似地,将第二奇偶校验位P2与在其第二位置具有1的所有数据位相关联(假设LSB位于第一位置),对第3和第4奇偶校验位使用类似的定义。
下表显示了数据和奇偶校验位之间的关联。“X”表示给定的奇偶校验位是数据位的函数。基于此表,我们得出以下等式来计算奇偶校验位。
)
数据和奇偶校验位的关系。
消息传输的算法如下。根据下面的等式计算奇偶校验位,然后将奇偶校验位分别插入位置1、2、4和8,并根据上上图通过添加数据位形成消息。一旦接收机的数据链路层收到消息,它首先提取奇偶校验位,并形成由四个奇偶校验位组成的形式为P=P4P3P2P1P=P4P3P2P1的数字,例如如果P1=0,P2=0,P3=1,P4=1,则P=1100。随后,接收器处的错误检测电路从接收到的数据位中计算出一组新的奇偶校验位(P′1,P′2,P′3,P′4P1′,P2′,P3′,P4′),并形成形式为P′=P′4P′3P′2P′1P′=P4′P3′P2′P1′的另一个数。理想情况下,P应该等于P0,但如果数据或奇偶校验位中存在错误,则情况不会如此。让我们计算P⊕P′P⊕P′,这个值也称为伴随式(syndrome)。
P1=D1⊕D2⊕D4⊕D5⊕D7P2=D1⊕D3⊕D4⊕D6⊕D7P3=D2⊕D3⊕D4⊕D8P4=D5⊕D6⊕D7⊕D8P1=D1⊕D2⊕D4⊕D5⊕D7P2=D1⊕D3⊕D4⊕D6⊕D7P3=D2⊕D3⊕D4⊕D8P4=D5⊕D6⊕D7⊕D8
现在尝试将伴随式的值与错误位的位置相关联。首先假设奇偶校验位中存在错误,在这种情况下,下表中的前四个条目显示了消息中错误位的位置和伴随式的值,伴随式的值等于消息中错误位的位置。奇偶校验位分别位于位置1、2、4和8,因此如果任何奇偶校验位有错误,其校正子中的对应位被设置为1,其余位保持为0。因此,校正子匹配错误位的位置。
错误位置与伴随式之间的关系。
现在考虑数据位中的单位错误的情况。再次从上表中可以得出结论,伴随式与数据位的位置相匹配,因为一旦数据位出现错误,所有相关的奇偶校验位都会被翻转。例如,如果D5有错误,则奇偶校验位P1和P4被翻转。回想一下,将P1和P4与D5关联的原因是因为D5是位号9(1001),而9的二进制表示中的两个1分别位于位置1和4。随后,当D5中存在错误时,校正子等于1001,这也是消息中位的索引。同样,每个数据和奇偶校验位都有一个独特的校正子(参见上上表)。
因此可以得出结论,如果存在错误,则伴随式指向错误位(数据或奇偶校验)的索引。如果没有错误,则伴随式等于0。因此有了检测和纠正单个错误的方法。这种用附加奇偶校验位编码消息的方法称为SEC(single error correction,单纠错)码。
单错误校正,双错误检测(SECDED)
现在尝试使用SEC代码来额外检测双重错误(两位错误)。举一个反例,证明基于伴随式的方法是行不通的。假设位D2和D3中存在错误,伴随式将等于0111,但如果D4中存在错误,伴随式也将等于0112。因此,无法知道是否存在单位错误(D4)或双位错误(D2和D3)。
稍微扩充一下算法来检测双重错误。添加一个额外的奇偶校验位P5,它计算SEC代码中使用的所有数据位(D1…D8)和四个奇偶校验位(P1…P4)的奇偶校验,然后将P5添加到消息中。将其保存在信息的第13位,并将其排除在计算伴随式的过程中。新算法如下。首先使用与SEC(单一错误校正)代码相同的过程来计算伴随式。如果伴随式为0,则不会有错误(单或双)。通过查看上上表,可以很容易地验证单个错误的证明。对于双重错误,假设两个奇偶校验位被翻转,在这种情况下,伴随式将有两个1。类似地,如果两个数据位被翻转,则伴随式将至少有一个1位,因为上上表中没有两个数据比特具有相同的列。现在,如果一个数据和一个奇偶校验位被翻转了,那么伴随式也将为非零,因为一个数据比特与多个奇偶校验比特相关联。正确的奇偶校验位将指示存在错误。
因此,如果伴随式是非零的,就可以怀疑有错误;否则假设没有错误。如果有错误,查看消息中的位P5,并在接收器处重新计算。让我们将重新计算的奇偶校验位指定为P'5。现在,如果P5=P'5,那么我们可以得出结论,存在双位错误。在计算最终奇偶校验时,两个单比特错误基本上是相互抵消的。相反,如果P5不等于P'5,则意味着有一个位错误。可以使用此检查来检测两位或一位是否有错误,如果有一个位错误,那么也可以纠正它。然而,对于双位错误,只能检测它,并可能要求源重新传输。该代码通常称为SECDED代码。
汉明码(Hamming Code)
迄今为止描述的所有代码都被称为汉明代码,因为它们隐含地依赖于汉明距离,汉明距离是两个二进制比特序列之间不同的对应比特数。例如,0011和1010之间的汉明距离为2(MSB和LSB不同)。
现在考虑一个4位奇偶校验码。如果消息为0001,则奇偶校验位等于1,且奇偶校验位位于MSB位置的发送消息为10001。不妨将发送消息称为码字(code word)。注意,00001不是一个有效的码字,接收者将依靠这个事实来判断是否存在错误,事实上,在有效码字的汉明距离1内没有其他有效码字。同样,对于SEC代码,码字之间的最小汉明距离为2,对于SECDED代码,最小汉明距离为3。现在考虑一种同样非常流行的不同类型的代码。
汉明纠错码。
汉明SEC-DEC码。
循环冗余校验(CRC)码
CRC(yclic Redundancy Check,循环冗余校验)码主要用于检测错误,即使在大多数情况下它们可以用于纠正单比特错误。为了激励CRC码的使用,让我们看看实际I/O系统中的错误模式。通常在I/O通道中,干扰持续时间比位周期长。例如,如果有一些外部电磁干扰,那么它可能会持续几个周期,并且可能会有几个比特被翻转。这种错误模式称为突发错误(burst error)。例如,32位CRC码可以检测长达32位的突发错误,它通常可以检测大多数2位错误和所有单位错误。
CRC码背后的数学十分复杂,感兴趣的读者可以参考有关编码理论的文本,下面展示一个小示例。
假设我们希望为8位消息计算4位CRC码。让消息等于二进制的101100112,第一步是将消息填充4位,即CRC码的长度,因此新消息等于10110011 0000(增加了一个空格以提高可读性)。CRC码需要另一个5位数字,即生成多项式或除数。原则上,需要将消息表示的数字除以除数表示的数字,剩余部分是CRC码。然而这种划分不同于常规划分,它被称为模2除法。在这种情况下,假设除数是110012。对于n位CRC码,除数的长度是n+1位。
现在阐述算法。首先将除数的MSB与消息的MSB对齐,如果消息的MSB等于1,就计算第一个n+1位和除数的XOR,并用结果替换消息中的相应位。否则,如果MSB为0,则不执行任何操作。在下一步中,将除数向右移动一步,将消息中与除数的MSB对齐的位视为消息的MSB,然后重复相同的过程。继续这一系列步骤,直到除数的LSB与消息的LSB对齐。最后,最小有效n(4位)包含CRC码。对于发送消息,在消息中附加CRC码。接收机重新计算CRC码,并将其与消息附加的码匹配。
具体示例,显示计算4位CRC码的步骤,其中消息等于10110011,除数等于11001。算法过程示意图如下:
此图忽略了消息相关部分的MSB为0的步骤,因为在这些情况下,无需执行任何操作。
Reed-Solomon
汉明码在人们可以合理预期错误是罕见事件的情况下工作得很好,固定磁盘驱动器的错误率约为1亿分之一,3位汉明码将很容易纠正这种错误,但汉明码在多个相邻比特可能被损坏(即突发错误)的情况下是无用的。由于它们暴露于处理不当和环境压力,在磁带和光盘等可移动介质上,突发错误很常见。
如果期望错误发生在块中,就应该使用基于块级(block level)操作的纠错码,而不是比特级(bit level)操作的汉明码。Reed-Solomon(所罗门,RS)码可以被认为是一种CRC,它在整个字符上运行,而不仅仅是几个比特。RS码和CRC一样,都是系统化的:奇偶校验字节被附加到一个信息字节块上。使用以下参数定义RS(n,k)RS(n,k)码:
- ss = 字符(或“符号”)中的位数。
- kk = 构成数据块的s位字符数。
- nn = 码字中的位数。
RS(n,k)RS(n,k)可以校正k个信息字节中的n−k2n−k2个错误。因此,流行的RS(255,223)RS(255,223)码使用223个8位信息字节和32个伴随式字节来形成255字节的码字,它将纠正信息块中多达16个错误字节。RS码的生成多项式由一个定义在抽象数学结构(称为Galois域)上的多项式给出,RS生成多项式为:
g(x)=(x−ai)(x−ai+1)…(x−ai+2t)g(x)=(x−ai)(x−ai+1)…(x−ai+2t)
其中t=n−kt=n−k和xx是整个字节(或符号),并且g(x)g(x)在字段GF(2S)GF(2S)上操作。注意,这个多项式在Galois域上展开,与普通代数中使用的整数域有很大不同。使用以下等式计算nn字节RSRS码字:
c(x)=g(x)×i(x)c(x)=g(x)×i(x)
其中i(x)i(x)是信息块。尽管RS纠错算法背后有令人望而生畏的代数,但它很适合在计算机硬件中实现,在大型计算机的高性能磁盘驱动器以及用于音乐和数据存储的光盘中实现。
19.8.4.3 仲裁
现在阐述总线仲裁(arbitration)的问题,“仲裁”一词的字面意思是“解决争端”。考虑一种多点总线,那里可能有多个发射机。如果多个发射机有兴趣通过总线发送值,需要确保在任何时间点只有一个发射机可以在总线上发送值。因此,需要一个仲裁策略来选择可以通过总线发送数据的设备。如果有点对点总线,其中有一个发送器和一个接收器,那么不需要仲裁。如果有不同类型的消息等待传输,就需要根据一些最优性标准来调度链路上的消息传输。
设想了一种称为仲裁器(arbiter)的专用结构,它执行总线仲裁的任务。所有设备都连接到总线和仲裁器,它们通过向仲裁器发送消息来表示它们愿意传输数据。仲裁器选择其中一个设备,有两种拓扑用于将设备连接到仲裁器。可以使用星形(star like)拓扑,也可以使用菊花链拓扑(daisy chain)。接下来的小节中讨论这两种方案。
星形拓扑
在这个集中式协议中,有一个称为仲裁器的中央实体,它是一个专用电路,接受来自所有希望在总线上传输的设备的总线请求。它强制执行优先级和公平性政策,并授予单个设备在总线上发送数据的权利。具体来说,在请求完成后,仲裁器查看所有当前请求,然后为选择发送数据的设备断言总线授权信号。所选设备随后成为总线主控器并获得总线的独占控制,然后它可以适当地配置总线,并传输数据。系统概述如下图所示。
集中的基于仲裁者的架构。
我们可以采用两种方法来确定当前请求何时完成。第一种方法是,连接到总线的每个设备在给定的周期数n内进行传输,在这种情况下,在经过n个周期后,仲裁器可以自动假设总线是空闲的,并且可以调度另一个请求。然而,情况可能并不总是这样,可能有不同的传输速度和不同的消息大小,在这种情况下,每个发送设备都有责任让仲裁器知道这已经完成。我们设想了一个额外的信号总线释放,每个设备都有一条到仲裁器的专用线路,用于发送总线释放信号。一旦完成了传输过程,它就断言这条线(将其设置为1)。随后,仲裁器将总线分配给另一个设备。它通常遵循标准策略,如循环或FIFO。
基于菊花链的仲裁
如果有多个设备连接到一条总线,仲裁器需要知道所有设备及其相对优先级。此外,当增加连接到总线的设备数量时,仲裁器开始出现高争用,并且变得缓慢。因此,希望有一个方案,可以容易地执行优先级,保证一定程度的公平性,并且不会在增加连接设备的数量时导致总线分配决策的缓慢。菊花链总线是考虑到所有这些要求而提出的。
下图显示了基于菊花链的总线的拓扑结构。该拓扑结构类似于线性链,一端有仲裁器。除最后一个设备外,每个设备都有两个连接。协议开始如下。一个设备从断言其总线请求线开始,所有设备的总线请求线以有线或方式连接,到仲裁器的请求线本质上计算所有总线请求线的逻辑或。随后,如果仲裁器具有令牌,则仲裁器将令牌传递给与其连接的设备,否则需要等待仲裁器获得释放信号。一旦设备获得令牌,它就成为总线主机,如果需要,它可以在总线上传输数据。发送消息后,每个设备将令牌传递给链上的下一个设备,该设备也遵循相同的协议。如果需要,它会传输数据,否则只传递令牌。最后,令牌到达链的末端。链上的最后一个设备断言总线释放信号,并销毁令牌,释放信号是所有总线释放信号的逻辑或。一旦仲裁器观察到要断言的释放信号,它就会创建一个令牌。在看到请求行设置为1后,它会将此令牌重新插入菊花链。
菊花链架构。
这个方案有几个微妙的优点。首先,有一个隐含的优先权概念,连接到仲裁器的设备具有最高优先级。渐渐地,当离开仲裁器时,优先级会降低。其次,该协议具有一定程度的公平性,因为在高优先级设备放弃令牌之后,它无法再次取回令牌,直到所有低优先级设备都获得令牌,所以设备不可能单独等待。其次,很容易将设备插入和移除到总线,我们从不维护设备的任何单独状态,所有到仲裁器的通信都是聚合的,我们只计算总线请求和总线释放线的OR函数。设备必须保持的唯一状态是关于其在菊花链中的相对位置以及其近邻的地址的信息。
我们也可以有完全避免中央仲裁器的纯分布式方案。在这种方案中,所有节点都独立地做出决策,但这种方案很少使用。
19.8.4.4 面向事务的总线
到目前为止,我们只关注单向通信,在任何一个时间点,只有一个节点可以向其他节点进行传输。现在考虑更现实的总线,实际上大多数高性能I/O总线都不是多点总线。多点总线可能允许多个发射机,尽管不是在同一时间点,现代I/O总线是点对点总线,通常有两个端点。其次,I/O总线通常由两条物理总线组成,因此可以进行双向通信。例如,如果有一条连接节点A和B的I/O总线,就可以同时向彼此发送消息。
一些早期的系统有一条总线,将处理器直接连接到内存。在这种情况下,处理器被指定为主处理器,因为它只能启动总线消息的传输。内存被称为从属内存,它只能响应请求。如今,主和从的概念已经淡化,但并发双向通信的概念仍然很普遍。双向总线被称为双工总线(duplex bus)或全双工总线(full duplex bus)。相比之下,可以使用半双工总线(half duplex bus),它只允许一方在任何时间点进行传输。
下图中的内存控制器芯片和DRAM模块之间的双工通信的典型场景,显示了内存读取操作的消息顺序和时序。实际上有两条总线。第一总线将存储器控制器连接到DRAM模块,它由地址线(承载存储器地址的线)和承载专用控制信号的线组成,控制信号指示操作的定时以及需要在DRAM阵列上执行的操作的性质。第二总线将DRAM模块连接到内存控制器,包括数据线(承载从DRAM读取的数据的线)和定时线(传送定时信息的线)。
DRAM读取时序。
协议如下。内存控制器通过断言RAS(行地址选通)信号开始,RAS信号激活设置字线值的解码器。同时,内存控制器将行的地址放置在地址线上,它估计了DRAM模块读取行地址所需的时间(trowtrow)。在trowtrow时间单位后,它断言CAS信号(列地址选通),并将列的地址放在总线上的DRAM阵列中,它还使能向DRAM模块指示它需要执行读访问的读信号。随后,DRAM模块读取存储器位置的内容并将其传输到其输出缓冲器。然后它断言就绪信号,并将数据放在总线上。但此时内存控制器不是空闲的,它开始在总线上放置下一个请求的行地址。注意,DRAM访问的时序非常复杂,连续消息的处理通常是重叠的,例如当第n个请求正在传输其数据时,我们可以继续解码第(n+1)个请求的行地址,可减少DRAM延迟,但为了支持这一功能,需要双工总线和复杂的消息序列。
让注意上图所示的基本DRAM访问协议的一个显著特征,请求和响应彼此之间的耦合非常强,源(存储器控制器)知道目的地(DRAM模块)的复杂性,并且源和目的地发送的消息的性质和定时之间存在强烈的相互关系。其次,在请求期间,内存控制器和DRAM模块之间的I/O链路被锁定,我们无法为原始请求和响应之间的任何干预请求提供服务。这种消息序列被称为总线事务(bus transaction)。
面向事务的总线有利弊。首先是复杂性,它们对接收机的定时做了很多假设,因此消息传输协议对于每种类型的接收机都非常特殊,对可移植性不利,插入具有不同消息语义的设备变得非常困难。此外,总线可能会被锁定很长一段时间,并有空闲时间,会浪费带宽。然而,在一些场景中,例如我们所展示的示例中,面向事务的总线表现非常好,并且优于其他类型的总线。
19.8.4.5 拆分事务总线
现在看一下试图纠正面向事务总线缺点的拆分事务总线,我们不假设不同节点之间的消息序列是严格的,比如对于DRAM和内存控制器示例,将消息传输分成两个较小的事务。首先,内存控制器向DRAM发送内存请求,DRAM模块缓冲消息,并继续进行内存访问。随后,它向内存控制器发送一个单独的消息,其中包含来自内存的数据,两个消息序列之间的间隔可以任意大。这种总线被称为拆分事务总线(split transaction bus),它将一个较大的事务拆分为更小、更短的单个消息序列。
这里的优点是简单性和可移植性,所有的传输基本上都是单向的。我们发送一条消息,然后不通过锁定总线来等待它的回复,发送者继续处理其他消息。每当接收器准备好响应时,它都会发送一条单独的消息。除了简单,这种方法还允许我们将各种接收器连接到总线,只需要定义一个简单的消息语义,任何符合该语义的接收器电路都可以连接到总线。我们不能使用此总线执行复杂的操作,例如重叠多个请求和响应,以及细粒度的定时控制。对于此类需求,可以始终使用支持事务的总线。
19.8.5 网络层
前面小节研究了如何设计全双工总线,具体来说,研究了信令、信号编码、定时、分帧、错误检查和事务相关问题。现在可以假设I/O总线在端点之间正确地传递消息,并确保及时和正确的传递。现在看看整个芯片组,它本质上是一个大型I/O总线网络。
本节解决的问题与I/O寻址有关。例如,如果处理器希望向USB端口发送消息,则需要有一种唯一寻址USB端口的方法。随后,芯片组需要确保将消息正确路由到适当的I/O设备。类似地,如果键盘等设备需要将按键的ASCII码发送给处理器,则需要有一种寻址处理器的方法。本节将查看芯片组中的路由消息。
19.8.5.1 I/O端口寻址
硬件I/O端口称为外部连接设备的连接端点。现在考虑一个软件端口,将其定义为一个抽象实体,它对软件来说是一个寄存器或一组寄存器,例如USB端口物理上包含一组金属管脚及运行USB协议的端口控制器。然而USB端口的软件版本,是一组可寻址的寄存器,如果希望写入USB设备,就将写入由USB端口暴露给软件的一组寄存器,USB端口控制器通过将处理器发送的数据物理写入连接的I/O设备来实现软件抽象。同样,为了读取I/O设备通过USB端口发送的值,处理器发出读取相应的端口控制器将I/O设备的输出转发给处理器。
下图说明这个概念。图中有一个物理硬件端口,它有一组金属引脚,以及实现物理和数据链路层的相关电路。端口控制器通过处理器发送的完整请求实现网络层,它还公开了一组8到32位寄存器,这些寄存器可以是只读、只读或读写的,例如,显示器等显示设备的端口包含只读寄存器,因为无法从中获取任何输入。类似地,鼠标的端口控制器包含只读寄存器,而扫描仪的端口控制器则包含读写寄存器,因为通常向扫描仪发送配置数据和命令,并从扫描仪读取文档的图像。
I/O端口的软件接口。
例如,Intel处理器需要64K(216)个8位I/O端口,可以将4个连续端口融合为32位端口,这些端口相当于汇编代码可访问的寄存器。其次,诸如以太网端口或USB端口的给定物理端口可以具有分配给它们的多个这样的软件端口,例如,如果希望一次性将大量数据写入以太网,就可能会使用数百个端口。Intel处理器中的每个端口都使用从0到0xFFFF不等的16位数字进行寻址,类似地,其他架构定义了一组I/O端口,这些端口充当实际硬件端口的软件接口。
让我们将术语I/O地址空间定义为操作系统和用户程序可访问的所有I/O端口地址的集合。I/O地址空间中的每个位置对应于一个I/O端口,该端口是物理I/O端口控制器的软件接口。
大多数指令集架构有两条指令:输入和输出。指令的语义如下。
指令 | 语义 |
---|---|
in r1, <I/O port> | I/O端口的内容传输到r1寄存器。 |
out r1, <I/O port> | r1寄存器的内容传输到I/O端口。 |
in指令将数据从I/O端口传输到寄存器。相反,out指令将数据从寄存器传输到I/O端口,是一种用于编程I/O设备的通用通用机制。例如,如果想要打印一页,就可以将整个页面的内容传输到打印机的I/O端口,最后将打印命令写入接受打印机命令的I/O端口,随后打印机可以开始打印。
现在让我们执行输入和输出指令。第一个任务是确保消息到达适当的端口控制器,第二个任务是在输出指令的情况下将响应路由回处理器。
让我们再次看看之前章节涉及的主板架构。CPU通过前端总线连接到北桥芯片,DRAM内存模块和图形卡也连接到北桥芯片,北桥芯片连接到处理速度较慢的设备的南桥芯片,南桥芯片连接到USB端口、PCI Express总线(及其连接的所有设备)、硬盘、鼠标、键盘、扬声器和网卡。这些设备中的每一个都有一组相关的I/O端口和I/O端口号。
通常,主板设计者有分配I/O端口的方案。让我们尝试构建一个这样的方案,假设有64K个8位I/O端口,就像Intel处理器一样,I/O端口的地址范围从0到0xFFFF。首先,将I/O端口分配给连接到北桥芯片的高带宽设备,给他们0到0x00FF范围内的端口地址,为连接到南桥芯片的设备划分其余地址,假设硬盘的端口范围为0x0100到0x0800,让USB端口的范围为0x0801到0x0FFF,为网卡分配以下范围:0x1000到0x4000,将剩余的几个端口分配给其他设备,并为以后可能要连接的任何新设备保留一部分空白。
现在,当处理器发出I/O指令(输入或输出)时,处理器识别出这是一条I/O指令,通过FSB(前端总线)向北桥芯片发送I/O端口地址和指令类型,北桥芯片为每种I/O端口类型及其位置维护一个范围表。一旦它看到来自处理器的消息,它就会访问这个表并找出目标的相对位置。如果目的地是直接连接到它的设备,则北桥芯片将消息转发到目的地。否则,它将请求转发到南桥芯片,南桥芯片维护I/O端口范围和设备位置的类似表。在该表中执行查找后,它将接收到的消息转发到适当的设备。这些表称为I/O路由表(I/O routing table),I/O路由表在概念上类似于大型网络和互联网使用的网络路由表。
对于反向路径,通常将响应发送到处理器。我们为处理器分配了一个唯一的标识符,消息由北桥和南桥芯片适当地路由。有时需要将消息路由到内存模块,使用类似的寻址方案。
该方案本质上将物理I/O端口集映射到I/O地址空间中的位置,专用I/O指令使用端口地址与它们通信。这种访问和寻址I/O设备的方法通常称为I/O映射I/O( I/O mapped I/O)。
19.8.5.2 内存映射寻址
现在再次查看输入和输出I/O指令。执行程序需要了解I/O端口的命名方案,不同的芯片组和主板可能使用不同的I/O端口地址,例如,一块主板可能会为USB端口分配I/O端口地址范围0xFF80到0xFFC0,另一块主板则可能会分配范围0xFEA0到0xFFB0。因此,在第一块主板上运行的程序可能无法在第二块主板上工作。
为了解决这个问题,需要在I/O端口和软件之间添加一个附加层,提出一种类似于虚拟内存的解决方案。事实上,虚拟化是解决计算机架构中各种问题的标准技术,后续继续设计用户程序和I/O地址空间之间的虚拟层。
假设在操作系统中有一个专用的设备驱动程序,该驱动程序专用于芯片组和主板。它需要了解I/O端口的语义,以及它们到实际设备的映射。考虑一个希望访问USB端口的程序(用户程序或操作系统),一开始,它不知道USB端口的I/O端口地址,因此它需要首先请求操作系统中的相关模块将其虚拟地址空间中的存储器区域映射到I/O地址空间的相关部分。例如,如果USB设备的I/O端口在0xF000到0xFFFF之间,那么I/O地址空间中的这个4 KB区域可以映射到程序虚拟地址空间中一个页面。需要在TLB和页表条目中添加一个特殊的位,以指示该页实际上映射到I/O端口。其次,需要存储I/O端口地址,而不是存储物理帧的地址。主板驱动程序的角色是操作系统的一部分,用于创建此映射。在操作系统将I/O地址空间映射到进程的虚拟地址空间之后,进程可以继续进行I/O访问。注意,在创建映射之前,需要确保程序具有足够的权限来访问I/O设备。
创建映射后,程序可以自由访问I/O端口。它使用常规加载和存储指令来写入虚拟地址空间中的位置,而不是使用I/O指令(如in和out)。在这样的指令到达流水线的内存访问(MA)阶段之后,有效地址被发送到TLB以进行转换。如果有TLB命中,那么管线也会意识到虚拟地址映射到I/O地址空间而不是物理地址空间的事实。其次,TLB还将虚拟地址转换为I/O端口地址,注意,在这个阶段不需要使用TLB,可以使用另一个专用模块来转换地址。在任何情况下,处理器在MA阶段接收等效的I/O端口地址。随后,它创建一个I/O请求并将该请求分派到I/O端口。
内存映射I/O是一种通过将I/O地址空间中的每个地址分配给进程虚拟地址空间中唯一的地址来寻址和访问I/O设备的方案。对于访问I/O端口,该过程使用常规加载和存储指令。
这种方案称为内存映射I/O,其主要优点是它使用常规加载和存储指令来访问I/O设备,而不是专用的I/O指令。其次,程序员不需要知道I/O地址空间中I/O端口的实际地址。由于操作系统和内存系统中的专用模块在I/O地址空间和进程的虚拟地址空间之间建立了映射,因此程序可以完全忽略寻址I/O端口的语义。
19.8.6 协议层
现在讨论I/O系统中的最后一层。前三层确保消息从I/O系统中的一个设备正确传递到另一个设备,现在看看完整I/O请求的级别,例如打印整个页面、扫描整个文档或从硬盘读取一大块数据。以打印文档为例。
假设打印机连接到USB端口。打印机设备驱动程序首先指示处理器将文档的内容发送到与USB端口关联的缓冲区,假设每个这样的缓冲区都分配了唯一的端口地址,并且整个文档都在缓冲区集中,此外假设设备驱动程序知道缓冲区是空的。要发送文档内容,设备驱动程序可以使用一系列输出指令,也可以使用内存映射的I/O。传输文档内容后,最后一步是将PRINT命令写入预先指定的I/O端口。USB控制器管理与其相关的所有I/O端口,并确保发送到这些端口的消息发送到连接的打印机,打印机在从USB控制器接收到PRINT命令后开始打印作业。
假设用户单击另一个文档的打印按钮。在将新文档发送到打印机之前,驱动程序需要确保打印机已完成前一文档的打印。此处的假设是,有一个简单的打印机,一次只能处理一个文档。因此,应该有一种方法让驱动程序知道打印机是否空闲。
在研究打印机与其驱动程序通信的不同机制之前,考虑一个类比场景,在这个场景中,Sofia正在等待一封信送达。如果这封信是通过Sofia的一个朋友寄来的,那么Sofia可以继续给她的朋友打电话,询问她何时会回来,一旦她回来,Sofia就可以去她家收信了。或者,发件人可以通过快递服务发送信件,在这种情况下,Sofia只需要等待快递员来送信。前者接收消息的机制称为轮询(polling),后者称为中断(interrupt)。后续小节会详细说明。
19.8.6.1 轮询
假设打印机中有一个名为状态寄存器的专用寄存器,用于维护打印机的状态。每当打印机的状态发生变化时,它都会更新状态寄存器的值。假设状态寄存器可以包含两个值,即0(空闲)和1(忙),当打印机打印文档时,状态寄存器的值为1(忙碌),当打印机完成文档打印时,它将状态寄存器的值设置为0(空闲)。
现在假设打印机驱动程序希望读取打印机的状态寄存器的值。它向打印机发送一条消息,要求它获取状态寄存器的数值。发送消息的第一步是向USB端口控制器的相关I/O端口发送字节序列,端口控制器依次将字节发送到打印机。如果它使用拆分事务总线,那么它将等待响应到达。同时,打印机解释消息,并将状态寄存器的值作为响应发送,USB端口控制器通过I/O系统将其转发给处理器。
如果打印机空闲,则设备驱动程序可以继续打印下一个文档,否则,它需要等待打印机完成。它可以继续向打印机请求状态,直到打印机空闲。这种反复查询设备状态直到其状态具有特定值的方法称为轮询(polling)。
轮询(polling)是一种等待I/O设备达到给定状态的方法,是通过在循环中重复查询设备的状态来实现的。
下面展示一段在假设系统中实现轮询的汇编代码,假设获取打印机状态的消息是0xDEADBEEF,需要首先将消息发送到I/O端口0xFF00,然后从I/O端口0xFF04读取响应。
/* 加载DEADBEEF到r0 */
movh r0, 0xDEAD
addu r0, r0, 0xBEEF
/* 轮询的循环 */
.loop:
out r0, 0xFF00
in r1, 0xFF04
cmp r1, 1
beq .loop /* 保持循环,直到status = 1 */
19.8.6.2 中断
基于轮询的方法有几个缺点。它使处理器保持忙碌,浪费电力,并增加I/O流量,可以改用中断。想法是向打印机发送消息,通知处理器何时空闲。打印机空闲后,或者如果打印机已经空闲,打印机会向处理器发送中断。I/O系统通常将中断视为常规消息,然后它将中断传递给处理器或专用中断控制器,这些实体意识到中断来自I/O系统。随后,处理器停止执行当前程序,并跳转到中断处理程序。
请注意,每个中断都需要标识自己或生成它的设备,主板上的每个设备通常都有一个唯一的代码,此代码是中断的一部分。在某些情况下,当我们将设备连接到通用端口(如USB端口)时,中断代码包含两部分,其中一部分是主板上连接到外部设备的端口的地址,另一部分是由主板上的I/O端口分配给设备的id。这种包含唯一代码的中断称为矢量中断(vectored interrupt)。
在某些系统(如x86机器)中,中断处理的第一阶段由可编程中断控制器(PIC)完成,这些中断控制器在x86处理器中称为APIC(高级可编程中断控制器),其作用是缓冲中断消息,并根据一组规则将它们发送给处理器。
现在看看PIC遵循的一套规则。大多数处理器在计算的某些关键阶段禁用中断处理,例如,当中断处理程序保存原始程序的状态时,我们不能允许处理器中断。成功保存状态后,中断处理程序可能会重新启用中断。在某些系统中,每当中断处理程序运行时,中断都会被完全禁用。一个密切相关的概念是中断屏蔽,它选择性地启用一些中断,并禁用一些其他中断,例如,我们可以在处理中断处理程序期间允许温度控制器的高优先级中断,并选择暂时忽略硬盘的低优先级中断。PIC通常有一个向量,每个中断类型有一个条目。它被称为中断掩码向量(interrupt mask vector)。对于中断,如果中断掩码向量中的对应位为1,则中断被启用,否则被禁用。
最后,如果在同一时间窗口内有多个中断到达,PIC需要尊重中断的优先级,比如来自具有实时约束的设备(如连接的高速通信设备)的中断具有较高的优先级,而键盘和鼠标中断具有较低的优先级。PIC使用考虑到其优先级和到达时间的启发式方法对中断进行排序,并按照该顺序将其呈现给处理器。随后,处理器根据前述章节说明的方法处理中断。
矢量中断(Vectored Interrupt):包含生成中断的设备id或连接到外部设备的I/O端口地址的中断。
可编程中断控制器(Programmable Interrupt Controller,PIC):用来缓冲、替换和管理发送给处理器的中断。
中断屏蔽(Interrupt Masking):用户或操作系统可以选择在程序的某些关键阶段(如运行设备驱动程序和中断处理程序时)选择性地禁用一组中断。这种机制被称为中断屏蔽。PIC中的中断掩码向量通常是位向量(每种中断类型一位),如果位设置为1,则中断被启用,否则被禁用,中断将被忽略,或在PIC中缓冲并稍后处理。
19.8.6.3 DMA
对于访问I/O设备,可以同时使用轮询和中断。在任何情况下,对于每个I/O指令,通常一次传输4个字节,意味着,如果需要将4KB块传输到I/O设备,就需要发出1024条输出指令。类似地,如果希望读入4KB的数据,就需要发出1024个指令。每个I/O指令通常需要十多个周期,因为它在经过几个级别的间接寻址后到达一个I/O端口。其次,I/O总线的频率通常是处理器频率的三分之一到四分之一。因此,大数据块的I/O是一个相当缓慢的过程,会使处理器长时间处于忙碌状态。目标是尽可能缩短设备驱动程序和中断处理程序等敏感代码。
因此,尝试设计一种可以加载处理器部分工作的解决方案。考虑一个类比场景,假设一位教授教授的班级有100多名学生,考试后需要给100多个剧本打分,这种情况将使她忙碌至少一周,而给剧本打分的过程是一个非常累和耗时的过程。因此,她可以将考试脚本评分的工作交给助教,确保教授有空闲时间,可以专注于解决最先进的研究问题。我们可以从这个例子中得到线索,并为处理器设计一个类似的方案。
设想一个称为DMA(直接内存访问)引擎的专用单元,它可以代表处理器做一些工作。具体而言,如果处理器希望将内存中的大量数据传输到I/O设备,反之亦然,那么DMA引擎可以代替发出大量I/O指令来承担责任。使用DMA引擎的过程如下:
- 设备驱动程序确定有必要在存储器和I/O设备之间传输大量数据。
- 它将内存区域的细节(字节范围)和I/O设备的细节(I/O端口地址)发送到DMA引擎,进一步规定了数据传输是从内存到I/O还是反向。
- 设备驱动程序挂起自己,处理器可以自由运行其他程序。同时,DMA引擎或DMA控制器开始在主存储器和I/O设备之间传输数据的过程。根据传输的方向,它会读取数据,暂时保存数据,然后将其发送到目的地。
- 一旦传输结束,它将向处理器发送一个中断,指示传输结束。
- I/O设备的设备驱动程序准备好恢复操作并完成所有剩余步骤。
现代处理器通常使用基于DMA的方法在主内存、硬盘或网卡之间传输大量数据。数据传输是在后台完成的,处理器基本上不会注意这个过程。其次,大多数操作系统都有库来编程DMA引擎以执行数据传输。
需要在DMA引擎的上下文中讨论两个微妙的点:
- 第一个是DMA控制器需要偶尔成为总线主控器。在大多数设计中,DMA引擎通常是北桥芯片的一部分,在需要时成为总线到内存的总线主控器,以及到南桥芯片的总线。它可以一次性传输所有数据,此法称为突发(burst)模式;或者,它等待总线中的空闲周期,并使用这些周期来调度自己的传输,此法称为周期窃取(cycle stealing)模式。
- 第二个微妙的问题是,如果不小心,可能会出现正确性问题。例如,可能在缓存中有一个给定的位置,同时DMA引擎正在向主内存中的位置写入数据。在这种情况下,缓存中的值将变得过时,而遗憾的是,处理器将无法知道这一事实。因此,确保DMA控制器访问的位置不在缓存中是很重要的。通常是通过一个称为DMA监听电路(snoop circuit)的专用逻辑块实现的,如果DMA引擎写入了缓存中的位置,则该逻辑块会动态逐出缓存中存在的位置。
DMA机制可以以多种方式配置,下图显示了一些可能性。在第一个示例中,所有模块共享相同的系统总线,DMA模块充当代理处理器,使用编程的I/O通过DMA模块在存储器和I/O模块之间交换数据,这种配置虽然可能很便宜,但显然效率很低。与处理器控制的编程I/O一样,每个字的传输消耗两个总线周期。
19.8.7 I/O协议
本节将描述几种最先进的I/O协议的操作,简要概述之。为了进行详细的研究,或者在有疑问的地方,可以查看网上发布的正式规范。正式规范通常由支持I/O协议的公司联盟发布,本节提供的大多数材料都来自于此。
19.8.7.1 PCI Express
大多数主板需要可用于将专用声卡、网卡和图形卡等设备连接到北桥或南桥芯片的本地总线。为了响应这一要求,1993年,一个公司联盟创建了PCI(外围组件互连)总线规范。
1996年,Intel创建了用于连接图形卡的AGP(加速图形端口)总线。在90年代末,许多新的总线类型被提出用于将各种硬件设备连接到北桥和南桥芯片。设计者很快意识到,拥有许多不同的总线协议会阻碍标准化工作,并迫使设备供应商支持多种总线协议。因此,一个公司联盟开始了标准化工作,并于2004年创建了PCI Express总线标准。该技术取代了大多数早期技术,迄今为止,它是主板上最流行的总线。
PCI express总线的基本思想是它是一种高速点对点串行(单位)互连。点对点互连只有两个端点,为了将多个设备连接到南桥芯片,创建了PCI express设备树。树的内部节点是PCI express交换机,可以多路复用来自多个设备的流量。其次,与旧协议相比,每个PCI Express总线在单个位线上串行发送位。通常高速总线避免使用多条铜线并行传输多个比特,因为不同的链路经历不同程度的抖动和信号失真。要保持不同导线中的所有信号彼此同步变得非常困难,因此,现代总线大多是串行的。
单个PCI Express总线实际上由许多单独的串行总线(称为通道)组成,每个通道都有其单独的物理层,PCI Express数据包在通道上分条(striped)。条带化意味着将一个数据块(数据包)划分为更小的数据块,并将它们分布在各个通道上,例如,在具有8个通道和8位数据包的总线中,可以在单独的通道上发送数据包的每一位。注意,在不同的通道上并行发送多个比特与具有多条线路发送数据的并行总线不同,因为并行总线对所有铜线都有一个物理层电路,而在这种情况下,每条通道都有其单独的同步和定时。数据链路层通过聚合从不同通道收集的每个数据包的子部分来完成成帧(framing)工作。
条带化(striping)过程是指将一个数据块划分为更小的数据块,并将它们分布在一组实体上。
通道由两条基于LVDS的导线组成,用于全双工信令。一根导线用于从第一个端点向第二个端点发送消息,第二根导线用于反向发送信号。一组通道被分组在一起以形成一个I/O链路,该链路被假定为传输完整的数据包(或帧)。然后,物理层将数据包传输到数据链路层,数据链路层执行纠错、流控制和实现事务。PCI Express协议是一种分层协议,其中每一层的功能大致类似于我们定义的I/O层。它没有将事务视为数据链路层的一部分,而是有一个单独的事务层。但是,除非另有说明,否则我们将使用本章中定义的术语来解释所有I/O协议。
PCI Express协议的规格汇总如下表所示,有1-32个通道,每条通道都是一条异步总线,它使用一种称为8bit/10bit编码的复杂数据编码。8bit/10bit编码在概念上可以被认为是NRZ协议的扩展,它将8个逻辑位的序列映射到10个物理位的序列,确保连续不超过五个1或0,可以有效地恢复时钟。回想一下,接收器通过分析数据中的转换来恢复发送器的时钟。其次,编码确保我们在传输信号中具有几乎相同数量的物理1和0。在数据链路层中,PCI Express协议实现了具有1-128字节帧和基于32位CRC的纠错的分割事务总线。
PCI Express总线通常用于连接通用I/O设备。有时有些插槽未使用,这样用户以后就可以为其特定应用程序连接卡,例如,如果用户对使用专用医疗设备感兴趣,那么她可以连接一个I/O卡,该卡可以从外部与医疗设备连接,也可以从内部连接到PCI Express总线。这种免费的PCI Express插槽称为扩展插槽(expansion slot)。
与QPI类似,PCIe是点对点架构,每个PCIe端口由多个双向通道组成(注意,QPI的通道仅指单向传输)。通过一对电线上的差分信号,在通道的每个方向上进行传输,PCI端口可以提供1、4、6、16或32个通道。
与QPI一样,PCIe使用多通道分发技术。下图显示了由四个通道组成的PCIe端口的示例。使用简单的循环方案将数据一次分配到四个通道1个字节,在每个物理通道上,每次缓冲和处理16字节(128位)的数据。128位的每个块被编码成用于传输的唯一130位码字,被称为128b/130b编码。因此,单个通道的有效数据速率降低了128/130倍。
PCIe多层配线。
下图说明了加扰(scrambling)和编码的使用。要传输的数据被送入扰频器,然后将加扰的输出馈送到128b/130b编码器,该编码器缓冲128位,然后将128位块映射到130位块。然后,该块通过并行到串行转换器,并使用差分信令一次传输一位。
19.8.7.2 SATA
现在看一下总线,它主要是为连接硬盘和光盘等存储设备而开发的。从80年代中期开始,设计师和存储供应商开始设计这种总线。随着时间的推移,开发了几种这样的总线,例如IDE(集成驱动电子)和PATA(并行高级技术附件)总线。这些总线主要是并行总线,其组成通信链路受到不同程度的抖动和失真。因此,这些技术被称为SATA(串行ATA)的串行标准所取代,是一种像PCI Express一样的点对点链路。
用于访问存储设备的SATA协议现在在绝大多数笔记本电脑和台式机处理器中使用,它已成为事实上的标准。SATA协议有三层:物理层、数据链路层和传输层。我们将SATA协议的传输层映射到协议层,每个SATA链路包含一对使用LVDS信令的单位链路。与PCI Express不同,SATA协议中的端点不可能同时读取和写入数据。在任何时间点只能执行其中一个操作,因此,它是一条半双工总线,使用8b/10b编码,并且是异步总线。数据链路层完成分帧工作。现在讨论网络层。由于SATA是一种点对点协议,因此可以以树结构连接一组SATA设备。树的每个内部节点都被称为乘数,它将请求从父级路由到其子级,或从其子级路由到父级。最后,协议层作用于帧并确保它们以正确的顺序传输,并实现SATA命令。具体来说,它实现DMA请求,访问存储设备,缓冲数据,并按预定顺序将其发送给处理器。
下表显示了SATA协议的规格。需要注意,SATA协议具有非常丰富的协议层,为基于存储的设备提供了多种命令,比如有专用命令来执行DMA访问、执行直接硬盘访问、编码和加密数据以及控制存储设备的内部。SATA总线是分离事务总线,数据链路层区分命令及其响应。协议层实现所有命令的语义。
19.8.7.3 SCSI和SAS
SCSI概述
现在讨论另一种适用于外围设备的I/O协议,即SCSI(发音为“scuzzy”)协议。SCSI最初是PCI的竞争对手,但随着时间的推移,它转变为连接存储设备的协议。
最初的SCSI总线是多点并行总线,可以有8到16个连接。SCSI协议区分主机和外围设备,例如南桥芯片是主机,而CD驱动器的控制器是外围设备,任何一对节点(主机或外围设备)都可以相互通信。与当今的高速总线相比,最初的SCSI总线是同步的,运行频率相对较低。SCSI至今仍然存在,最先进的SCSI总线使用80-160 MHz时钟并行传输16位,因此它们的理论最大带宽为320-640MB/s。请注意,串行总线可以达到1GHz,更通用,并且可以支持更大的带宽。
考虑到多点并行总线存在问题,设计人员开始将SCSI协议重新定位为点对点串行总线。回想一下,PCI Express和SATA总线也是出于同样的原因创建的。因此,设计者提出了一系列扩展了原始SCSI协议的总线,但本质上是点对点串行总线。两种这样重要的技术是SAS(串行连接SCSI)和FC(双通道)总线。FC总线主要用于超级计算机等非常高端的系统,SAS总线更常用于企业和科学应用。
因此,让我们主要关注SAS协议,因为它是当今使用的SCSI协议的最流行变体。SAS是一种串行点对点技术,也与以前版本的基于SATA的设备兼容,其规格与SATA规格非常接近。
SAS概述
SAS被设计为与SATA向后兼容,因此这两种协议在物理层和数据链路层上没有很大不同,但仍然存在一些差异。最大的区别是SAS允许全双工传输,而SATA仅允许半双工传输。其次,SAS通常可以支持更大的机架尺寸,并且与SATA相比,SAS支持端点之间更大的电缆长度(SAS为8米,SATA为1米)。
网络层与SATA不同。SAS没有使用乘法器(用于SATA),而是使用一种更复杂的结构,称为扩展器,用于连接多个SAS目标。传统上,SAS总线的总线主节点称为启动器,而另一个节点称为目标节点。有两种扩展器:边缘扩展器和扇出扩展器,边缘扩展器最多可用于连接255个SAS设备,扇出扩展器最多可连接255个边缘扩展器。我们可以使用根节点和一组扩展器在基于树的拓扑中添加大量设备,启动时为每个设备分配一个唯一的SCSI id,设备可以进一步细分为几个逻辑分区,例如,写入者目前正在处理一个被划分为两个逻辑分区的存储系统,每个分区都有一个逻辑单元号(LUN)。路由算法如下:如果存在直接连接,启动器会直接向设备发送命令,或者向扩展器发送命令。扩展器有一个详细的路由表,根据其SCSI id维护设备的位置,它查找此路由表并将数据包转发到设备或边缘扩展器。此边缘扩展器具有另一个路由表,用于将命令转发到适当的SCSI设备,然后SCSI设备将该命令转发到相应的LUN。对于向另一个SCSI设备或处理器发送消息,请求遵循反向路径。
最后,协议层对于SAS总线非常灵活。它支持三种协议,可以使用SATA命令、SCSI命令或SMP(SAS管理协议)命令。SMP命令是用于配置和维护SAS设备网络的专用命令。SCSI命令集非常广泛,旨在控制一系列设备(主要是存储设备),请注意,在向设备发送SCSI命令之前,设备必须与SCSI协议层兼容。如果设备不理解某个命令,那么可能会发生灾难性的事情,例如如果想读取CD,但CD驱动程序不理解该命令,那么它可能会弹出CD。更糟糕的是,它可能永远不会弹出CD,因为它不理解弹出命令。同样的论点也适用于SATA的情况。如果希望使用SATA命令,需要SATA兼容设备,如SATA兼容硬盘驱动器和SATA兼容光盘驱动器。由于协议层的灵活性,SAS总线在设计上与SATA设备和SAS/SSCSI设备兼容。对于协议层,SAS启动器向SAS/SSCSI设备发送SCSI命令,向SATA设备发送SATA命令。
近线SAS(NL-SAS)驱动器本质上是SATA驱动器,但具有将SCSI命令转换为SATA命令的SCSI接口。因此,NL-SAS驱动器可以在SAS总线上无缝使用。由于SCSI命令集更具表现力和效率,NL-SAS驱动器的速度比纯SATA驱动器快10-20%。
现在用4句话来简单描述SCSI命令集。启动器首先向目标发送命令,每个命令都有一个1字节的标头,并且具有可变长度的有效负载。然后,目标发送带有命令执行状态的回复,SCSI规范为设备控制和数据传输提供了至少60种不同的命令。
19.8.7.4 USB
概述
USB协议主要用于将外部设备连接到笔记本电脑或台式电脑,如键盘、鼠标、扬声器、网络摄像头和打印机。在90年代中期,供应商意识到存在多种I/O总线协议和连接器,主板设计者和设备驱动程序编写者很难支持大量设备。因此需要标准化,一个公司联盟(DEC、IBM、Intel、Nortel、NEC和Microsoft)构想了USB协议(通用串行总线)。
USB协议的主要目的是为各种设备设计一个标准接口。设计者一开始将设备分为三种类型,即低速(键盘、鼠标)、全速(高速音频)和高速(扫描仪和摄像机)。截至2012年,已经提出了三个版本的USB协议,即版本1.0、2.0和3.0。基本USB协议大致相同,协议向后兼容,意味着具有USB 3.0端口的现代计算机支持USB 1.0设备。与为特定硬件集设计的SAS或SATA协议不同,USB协议的设计非常通用,因此可以对目标设备的行为进行大量假设。因此,设计者需要为操作系统提供广泛的支持,以发现设备的类型、需求,并对其进行适当配置。其次,许多USB设备没有电源,例如键盘和鼠标。有必要在USB电缆中包括用于运行连接的设备的电源线。USB协议的设计者牢记了所有这些要求。
从一开始,设计者就希望USB成为一种快速协议,能够在未来支持高速设备,如高清视频。因此,他们决定使用点对点串行总线(类似于PCI Express、SATA和SAS)。每台笔记本电脑、台式机和中型服务器的前面板或后面板上都有一系列USB端口,每个USB端口都被视为可以与一组USB设备连接的主机。由于使用串行链接,可以创建一个类似于PCI Express和SAS设备树的USB设备树。大多数时候,只将一个设备连接到USB端口。但不是唯一的配置,也可以连接一个USB集线器,它就像树的内部节点。USB集线器原则上类似于SATA乘法器和SAS扩展器。
USB集线器大部分时间是被动设备,通常有四个端口连接到下游的其他设备和集线器,集线器最常见的配置包括一个上游端口(连接到父节点)和四个下游端口。我可以用这种方式创建一个USB集线器树,并将多个设备连接到主板上的单个USB主机。USB协议支持每个主机127个设备,最多可以串行连接5个集线器。集线器可以由主机供电,也可以自供电,如果集线器是自供电的,它可以连接更多设备,因为USB协议对其可以传输到任何单个设备的电流量有限制,目前,它被限制为500 mA,并且功率以100 mA的块分配。因此,由主机供电的集线器最多可以有4个端口,因为它可以给每个设备100 mA,并保持100 mA。有时,集线器需要成为活动设备,每当USB设备与集线器断开连接时,集线器就会检测到此事件,并向处理器发送消息。
USB协议的层
- 物理层
现在更详细地讨论协议,并从物理层开始。标准USB连接器有4个引脚,第一个引脚是提供固定5V DC电压的电源线,通常被称为Vbus的Vcc。差分信号有两个引脚,即D+和D-,其默认电压设置为3.3V。第四个引脚是接地引脚(GND),迷你和微型USB连接器有一个称为ID的附加引脚,有助于区分与主机和设备的连接。
USB协议使用差分信令,它使用NRZI协议的变体。对于编码逻辑位,它假设逻辑0由物理位中的转换表示,而逻辑1由无转换表示(与传统NRZI协议相反)。USB总线是一种恢复时钟的异步总线,为了帮助时钟恢复,如果数据中没有转换,则同步子层引入虚拟转换。例如,如果我们有1的连续运行,那么传输的信号中就不会有跃迁。在这种情况下,USB协议在每次运行6个1后引入0位。该策略确保了信号中有一些保证的过渡,并且接收机可以恢复发射机的时钟而不失同步。USB连接器只有一对用于差分信号的导线,因此全双工信令是不可能的,相反,USB链路使用半双工信令。
- 数据链接层
对于数据链路层,USB协议使用基于CRC的错误检查和可变帧长度,它使用位填充(专用帧开始和结束符号)来划分帧边界。仲裁在USB集线器中是一个相当复杂的问题,因为有很多种流量和很多种设备。USB协议定义了四种流量:
- 控制:用于配置设备的控制消息。
- 中断:需要紧急发送到设备的少量数据。
- 大量(Bulk):大量数据,没有任何延迟和带宽保证。如扫描仪中的图像数据。
- 同步(Isochronous):具有延迟和带宽保证的固定速率数据传输。 如网络摄像机中的音频/视频。
随着流量的不同,我们有不同种类的USB设备,即低速设备(192 KB/s)、全速设备(1.5 MB/s)和高速设备(60 MB/s),USB 3.0协议引入了需要384 MB/s的高速设备。
现在,有可能将高速和低速设备连接到同一个集线器。假设高速设备正在进行批量传输,而低速设备正在发送中断。在这种情况下,需要优先考虑枢纽上游链路的接入。仲裁很难,因为需要符合每类流量和每类设备的规范,在执行批量传输和发送中断之间陷入了两难境地。理想情况下,希望通过使用不同的流量优先级启发式方法,在冲突的需求之间取得平衡。有关仲裁机制的详细说明,可参阅USB规范。
现在考虑事务问题。假设高速集线器连接到主机,高速集线器(hub)还连接到下游的全速和低速设备,在这种情况下,如果主机通过高速集线器启动到低速设备的事务,那么它必须等待从设备获得回复,因为高速集线器和设备之间的链接很慢。在这种情况下,没有理由锁定主机和集线器之间的总线。可以改为实现拆分事务,拆分事务的第一部分将命令发送到低速设备,拆分事务的第二部分包括从低速设备到主机的消息。在拆分事务之间的间隔内,主机可以与其他设备通信。USB总线为许多其他类型的场景实现了类似的拆分事务(请参阅USB规范)。
- 网络层
现在考虑网络层。包括集线器的每个USB设备都由主机分配一个唯一的ID,由于每个主机最多可以支持127个设备,因此需要一个7位设备id。其次,每个设备都有多个I/O端口,每个这样的I/O端口都称为端点。我们可以有数据端点(中断、批量或同步),也可以有控制端点。此外,可以将端点分类为IN或OUT,IN端点表示只能向处理器发送数据的I/O端口,OUT端点接受来自处理器的数据。每个USB设备最多可以有16个IN端点和16个OUT端点,任何USB请求都明确规定了它需要访问的端点类型(IN或OUT)。考虑到端点的类型由请求固定,只需要4位就可以指定端点的地址。
所有USB设备都有一组默认的IN和OUT端点,其id等于0,这些端点用于激活设备并与其建立通信,随后每个设备定义其自定义端点集。简单的设备,如鼠标或键盘,通常只需一个IN端点即可将数据发送到处理器。然而更复杂的设备(如网络摄像头)需要多个端点。一个端点用于视频馈送,一个端点是音频馈送,并且可以有多个端点用于交换控制和状态数据。
集线器负责将消息路由到正确的USB设备,集线器维护将USB设备与本地端口ID关联的路由表。一旦消息到达设备,它就会将其路由到正确的端点。
- 协议层
USB协议层相当复杂。首先,在端点之间定义两种连接,称为管道,它将流管道定义为没有任何特定消息结构的数据流。相比之下,消息管道更加结构化,并且定义了发送方和接收方都必须遵循的消息序列,消息管道中的典型消息由三种数据包组成。通信以令牌包开始,令牌包包含设备id、端点id、通信性质和有关连接的附加信息。路径上的集线器将令牌分组路由到目的地,从而建立连接。然后,根据传输的方向(主机到设备或设备到主机),主机或设备发送一系列数据包。最后,在数据分组序列的末尾,分组的接收器发送握手分组以指示I/O请求的成功完成。
总结
下表总结了USB的讨论,可以参考USB协议的规范以获取更多信息。
19.8.8 存储
在所有通常连接到处理器的外围设备中,存储设备有一个特殊的位置,主要是因为它们是计算机系统功能的组成部分。
存储设备保持持久状态。持久状态指的是计算机系统中存储的所有数据,即使它通电时也是如此。值得注意的是,存储系统存储操作系统、所有程序及其相关数据,包括所有文档、歌曲、图像和视频。从计算机架构师的角度来看,存储系统在引导过程中扮演着积极的角色,保存文件和数据以及虚拟内存。让我们逐一讨论这些角色。
当处理器启动时(该过程称为引导),它需要加载操作系统的代码。通常操作系统的代码在主硬盘的地址空间的开头可用,然后处理器将操作系统的代码加载到主存储器中,并开始执行它。在引导过程之后,用户可以使用操作系统来运行程序和访问数据。程序在存储系统中保存为常规文件,数据也保存在文件中。文件本质上是硬盘或类似存储设备中的数据块,这些数据块需要读入主内存,以便处理器可以访问。
最后,存储设备在实现虚拟内存方面发挥着非常重要的作用,它们存储交换空间,交换空间包含主内存中无法包含的所有帧,有效地帮助扩展物理地址空间以匹配虚拟地址空间的大小。一部分帧存储在主内存中,其余帧存储在交换空间中。当出现页面错误时,它们被引入(交换)。
几乎所有类型的计算机都连接了存储设备,但也有一些例外,某些机器,特别是在实验室环境中,可能通过网络访问硬盘。它们通常使用网络启动协议从远程硬盘启动,并通过网络访问包括交换空间在内的所有文件。从概念上讲,它们仍然有一个连接的存储设备。它只是没有物理连接到主板,尽管如此,仍然可以通过网络访问。
现在看看主要的存储技术。传统上,磁存储一直是主导技术,这种存储技术记录了大型铁磁磁盘微小区域中的位值。根据磁化状态,可以推断逻辑0或1。可以使用光盘技术,如CD/DVD/蓝光驱动器,而不是磁盘技术。CD/DVD/蓝光光盘包含一系列凹坑(表面像差),这些凹坑编码一系列二进制值,光盘驱动器使用激光读取存储在磁盘上的值。计算机的大多数操作通常访问硬盘,而光盘主要用于存档视频和音乐,但从光盘驱动器启动并不罕见。
固态驱动器是磁盘和光盘的快速替代品。与具有移动部件的磁性和光学驱动器不同,固态驱动器由半导体制成。固态驱动器中最常用的技术是闪存,闪存设备使用存储在半导体中的电荷来表示逻辑0或1,它们比传统硬盘驱动器快得多,但可以存储的数据要少得多,截至2012年,其成本要高出5-6倍。因此,高端服务器选择混合解决方案,有一个快速的SSD驱动器,可以作为更大硬盘的缓存。
19.8.8.1 硬盘
从笔记本电脑到服务器,硬盘是大多数计算机系统的组成部分。它是一种由铁磁材料和机械部件制成的存储设备,可以以低成本提供大量存储容量。因此,在过去三十年中,硬盘一直被专门用于保存个人计算机、服务器和企业级系统中的持久状态。
令人惊讶的是,数据存储的基本物理原理非常简单,在一系列磁铁中保存0和1,现在快速回顾一下硬盘中数据存储的基本物理。
硬盘数据存储物理学
考虑一个典型的磁体,它有北极和南极,同极相互排斥,相反极相互吸引。除了机械性能外,磁体还具有电学性能,例如,当通过电线线圈的磁场由于磁体和线圈之间的相对运动而改变时,根据法拉第定律,电线两端会感应出EMF(电压)。硬盘使用法拉第定律作为其操作的基础。
硬盘的基本元素是一个小磁铁。硬盘中使用的磁体通常由氧化铁制成,具有永久磁性,意味着它们的磁性一直保持不变。它们被称为永磁体或铁磁体(因为氧化铁)。相比之下,可以拥有由缠绕在铁棒上的载流电线线圈组成的电磁铁,电流切断后,电磁铁失去磁性。
现在考虑一组串联的磁体,如下图所示。它们的相对方向有两种选择,即N-S(北-南)或S-N(南-北)。现在在磁铁的排列上移动一小圈电线,每当它穿过两个方向相反的磁体的边界时,磁场就会发生变化。因此,作为法拉第定律的直接结果,线圈两端感应出EMF。然而,当磁场方向没有变化时,线圈两端感应的EMF可以忽略不计。微小磁体方向的转变对应于逻辑1位,而没有转变表示逻辑0位。因此,图中的磁体表示位模式0101,类似于I/O通道的NRZI编码。
硬盘表面的一系列微小磁铁。
由于在转换中编码数据,因此需要保存数据块,硬盘在扇区中保存一块数据。硬盘扇区的大小在512字节之间,它被视为一个原子块,通常一次读取或写入整个扇区,包含小线圈并穿过磁铁的结构称为读取头。
现在看看将数据写入硬盘。在这种情况下,任务是设置磁铁的方向,还有一种叫做写头的结构,它包含一个微型电磁铁。如果电磁铁经过永久磁铁,它会引起永久磁铁的磁化。其次,磁化方向取决于电流的方向,如果改变电流的方向,磁化的方向就会改变。
为了简洁起见,将读磁头和写磁头的组合组件称为磁头。
盘片结构
硬盘通常由一组盘片组成。盘片是一个中间有孔的圆形圆盘,一个主轴通过中间的圆孔连接到盘片上。盘片被分成一组称为轨道(track,亦称磁道)的同心环,轨道被进一步划分为固定长度的扇区(sector),如下图所示。
硬盘由多个盘片组成。盘片是一个固定在主轴上的圆盘,盘片还包括一组称为磁道的同心环,每个磁道由一组扇区组成。扇区通常包含固定数量的字节,而与磁道无关。
现在概述一下硬盘的基本操作。盘片连接到主轴上。在硬盘操作过程中,主轴及其连接的盘片不断旋转。为了简单起见,假设一个单盘磁盘。第一步是将磁头定位在包含所需数据的磁道上。接下来,磁头需要在此位置等待,直到所需扇区到达磁头下方。由于盘片以恒定的速度旋转,可以根据头部的当前位置计算需要等待的时间。一旦所需扇区到达头部下方,就可以继续读取或写入数据。
这里需要考虑一个重要问题。每个轨道的扇区数是相同的还是不同的?请注意,每个磁道可以保存的位数存在技术限制。因此,如果每个磁道的扇区数相同,那么实际上是在向外围浪费磁道中的存储容量,因为受限于最接近中心的磁道中可以存储的位数。因此,现代硬盘避免了这种方法。
尝试为每个轨道存储可变数量的扇区。朝向中心的轨道包含更少的扇区,而朝向外围的轨道包含更多的扇区。这一计划也有其自身的问题。比较最内侧和最外侧轨道,并假设最内侧轨道包含N个扇区,最外侧轨道包含2N个扇区。如果假设每分钟的旋转数是恒定的,那么需要在最外层轨道上读取数据的速度是最内层轨道的两倍。事实上,每一条轨道的数据检索率都是不同的,使得磁盘中的电子电路复杂化。可以探索另一种选择,即以不同的速度为每个磁道旋转磁盘,以使数据传输速率保持恒定。在这种情况下,电子电路更简单,但以各种不同速度运行主轴电机所需的复杂程度令人望而却步。因此,这两种解决方案都是不切实际的。
怎么样,把两个不切实际的解决方案结合起来,让它变得实用!将这组轨迹划分为一组区域,每个区域由一组连续的m条轨道组成。如果盘片中有n个轨道,那么有n=m个区域。在每个区域中,每个磁道的扇区数是相同的,盘片以恒定的角速度旋转一个区域中的所有轨道。在一个区域中,与朝向盘片外围的磁道相比,更靠近中心的磁道的数据更密集。换言之,扇区在一个区域中的磁道具有物理上不同的大小。这不是问题,因为磁盘驱动器假设在一个区域中通过每个扇区所需的时间相同,并且以恒定的角速度旋转可以确保这一点。
下图显示了将盘片划分为区域的概念图,请注意,每个磁道的扇区数因区域而异。这种方法被称为分区位记录(Zoned-Bit Recording,ZBR)。我们没有考虑的两个不切实际的设计是ZBR的特例。第一种设计假设我们有一个区域,第二种设计假设每个轨道属于不同的区域。
分区位记录。
磁盘布局方法的比较:(a) 恒定角速度,(b)多区记录。
现在看看这个方案为什么有效。由于有多个分区,因此浪费的存储空间不如仅使用单个分区的设计高。其次,由于区域的数量通常不是很大,因此主轴的电机不需要频繁地重新调整其速度。事实上,由于空间位置的原因,留在同一区域的可能性相当高。
硬盘结构
现在将所有部件放在一起,看看下面两图的硬盘结构。有一组连接到单个旋转主轴(spindle)的盘片(platter),以及一组盘片臂(disk arm,盘片的每一侧一个),其末端包含一个磁头(head)。通常,所有臂一起移动,所有头部在同一圆柱体上垂直对齐。在这里,圆柱体(cylinder)被定义为来自多个盘片的一组轨道,这些盘片具有相同的半径。在大多数硬盘中,一个时间点只有一个磁头被激活,它对给定扇区执行读或写访问。在读取访问的情况下,数据被传输回驱动电子设备进行后处理(成帧、纠错),然后通过总线接口在总线上发送到处理器。
硬盘结构。
现在考虑一下硬盘设计中的一些细微之处(参见下图)。它显示了连接到主轴的两个盘片,每个盘片都有两个记录表面。主轴连接到电机(motor,称为主轴电机),该电机根据我们希望访问的区域调整其速度。所有臂组一起移动,并使用心轴连接到致动器(actuator)。致动器是一个小型电机,用于顺时针或逆时针移动臂。致动器的作用是通过顺时针或逆时针旋转给定的角度,将臂的头部定位在指定的轨道上。
硬盘内部结构。
桌面处理器中的典型磁盘驱动器的磁道密度约为每英寸10000个磁道,意味着轨道之间的距离为2.5微米,因此致动器必须非常精确。通常,扇区上有一些标记,指示轨道的编号,致动器通常需要进行轻微调整以达到准确的点。这种控制机制被称为伺服控制(servo control)。致动器和主轴电机均由硬盘机箱内的电子电路控制,一旦致动器将磁头放置在正确的轨道上,它需要等待所需的扇区到达磁头下方,轨道上有标记以指示扇区的编号。磁头放置在轨道上后,会继续读取标记。根据这些标记,它可以准确预测所需扇区何时位于头部下方。
除了机械部件外,硬盘还具有包括小型处理器在内的电子部件。它们在总线上接收和传输数据,在硬盘上调度请求,并执行纠错。硬盘是人类工程学的一项令人难以置信的成就。硬盘可以在大多数时间无缝地容忍错误,动态地使坏扇区(有故障的扇区)无效,并将数据重新映射到好扇区。
下图说明了与任何SSD系统相关的通用体系结构系统组件的一般视图。在主机系统上,操作系统调用文件系统软件来访问磁盘上的数据,文件系统反过来调用I/O驱动程序软件,I/O驱动程序软件提供对特定SSD产品的主机访问。图中的接口组件是指主机处理器和SSD外围设备之间的物理和电气接口,如果设备是内部硬盘驱动器,则通用接口为PCIe。对于外部设备,一个通用接口是USB。
固态驱动器架构。
硬盘存取的数学模型
现在,让我们为请求完成对硬盘的访问所需的时间构建一个快速的数学模型。可以把花费的时间分成三部分:
- 第一个是寻道时间,定义为磁头到达正确轨道所需的时间。
- 头部需要等待所需扇区到达其下方,该时间间隔称为旋转延迟。
- 磁头需要读取数据,处理数据以消除错误和冗余信息,然后在总线上传输数据,被称为传输时间。
因此,有一个简单的方程式描述之:
Tdisk\_access =Tseek +Trot\_latency +Ttransfer Tdisk\_access =Tseek +Trot\_latency +Ttransfer
除此之外,还有RAID阵列、光盘、闪存盘等介质,更多可参阅:18.11 文件和I/O。
19.9 GPU
19.9.1 概述
高强度图形是当代计算机系统的标志。今天的计算机,从智能手机到高端台式机,都使用各种复杂的视觉效果来增强用户体验。此外,用户还可以使用计算机玩图形密集型游戏、观看高清视频,以及进行计算机辅助工程设计,所有这些应用程序都需要大量的图形处理。
在早期,计算机中的图形支持非常初级,程序员需要指定屏幕上绘制的每个形状的坐标,例如要绘制一条线,程序员需要明确提供该线的坐标,并指定其颜色。颜色的范围非常有限,而且几乎没有用于卸载图形密集型任务的硬件。由于在屏幕上绘制的每一条线或圆都需要几个汇编语句,因此创建和使用计算机图形的过程非常缓慢。渐渐地,需要在硬件中对图形进行一些支持。
由于GPU和CPU是为两种截然不同的应用程序而设计和优化的,因此它们的体系结构存在显著差异,可以通过比较两种处理器技术专用于高速缓存、控制逻辑和处理逻辑的管芯面积(晶体管计数)的相对数量来看出(下图)。
CPU和GPU在缓存、ALU、控制器等硬件单元的对比图。
19.9.1.1 图形应用
我们可以将现代图形应用程序分为两种类型。第一类是自动图像合成。例如考虑游戏中的一个复杂场景,其中一个角色在月明的夜晚拿着机枪奔跑。在这种情况下,程序员不是手动将每个像素的值设置为给定的颜色,此过程太慢且耗时。如果使用这种方法,互动游戏都不会起作用。相反,程序员在高级对象级别编写程序,例如他可以用道路、植物和障碍物等一组对象来定义场景,可以塑造一个角色,以及随身携带的诸如机关枪、小刀和斗篷等工艺品,程序员根据这些对象编写程序。此外,他还可指定了一组规则来定义这些对象的交互,例如,如果角色与墙发生碰撞,则该角色会转身并朝另一个方向运行。除了定义对象和对象的语义外,还必须定义场景中的光源。在这种情况下,程序员需要指定月光下夜晚的光线强度。然后,通过专用图形软件和硬件自动计算角色和背景的照度。
遗憾的是,图形硬件不理解复杂对象和字符的语言。因此,大多数图形工具包都有图形库来将复杂结构分解为一组基本形状,计算机图形应用程序中的大多数形状都被分解为一组三角形,所有操作(如对象碰撞、移动、照明、阴影和照明)都转换为三角形上的基本操作。然而,图形库不使用常规处理器来处理这些三角形,并最终创建要在计算机屏幕上显示的像素阵列。一旦程序员的意图转化为对基本形状的操作,图形库就会将代码发送到专用图形处理器,该处理器完成其余的处理。图形处理器根据用户提供的数据和规则生成复杂场景,对由边和顶点指定的形状进行操作。大多数时候,这些形状是二维空间中的三角形,或者三维空间中的四面体。图形处理器还在生成最终图像时计算照明、对象位置、深度和透视的效果,一旦图形处理器生成了最终图像,它就会将其发送到显示设备。如果我们在玩电脑游戏,那么这个过程需要每秒至少进行50-100次。
总之,由于生成复杂的图形场景既困难又缓慢,因此程序员对对象进行高级描述。随后,图形库将程序员的指令转换为对基本形状的操作,并将一组形状和对其进行操作的规则发送给图形处理器。图形处理器通过对基本形状进行操作,然后将其转换为像素阵列来生成最终场景。
图形处理器的第二个重要应用是显示视频等动画内容,高清晰度视频每个场景有数百万像素。为了减少存储需求,大多数高清晰度视频都经过了严格压缩(编码)。因此,计算机需要解码或解压缩视频,每秒计算50-100次像素阵列,并在屏幕上显示它们。这是一个非常计算密集的过程,可能占用CPU的资源。因此,视频解码通常也被加载到图形处理器,该处理器包含处理视频的专用单元。
几乎所有现代计算机系统都包含图形处理器,它被称为GPU(Graphics Processing Unit,图形处理单元)。现代GPU包含超过64-128个内核,因此设计用于广泛的并行处理。
19.9.1.2 图形管线
现在让我们看看下图中的典型图形处理器的流水线。
图形管线。
第一阶段称为顶点处理。在此阶段,将处理一组顶点、形状和三角形。GPU执行复杂的操作,例如对象旋转和平移。程序员可能会指定给定的对象以一定的速度朝向另一个对象移动,因此有必要以给定的速率平移形状的位置,这种操作也在这个阶段进行。此阶段的输出是2D平面中的一组简单三角形。
第二阶段称为光栅化。光栅化过程将每个三角形转换为一组像素,称为片元(或片段)。此外,它将片元中的每个像素与一组参数相关联,这些参数稍后用于插值颜色的值。
第三阶段是片元处理。该阶段使用前一阶段计算的中间结果根据一组固定规则对片元的像素进行着色,或者将给定纹理映射到片元。例如,如果一块片元代表一张木制桌子的表面,那么这个阶段将木材的纹理映射到像素的颜色。此阶段还用于合并阴影和照明等效果。
注意,到目前为止,我们已经计算了场景中所有对象的片元颜色。然而,一个对象可能位于另一个对象的前面,因此第二个对象的一部分可能被隐藏。
第四阶段聚合来自第三阶段的所有片元,并执行称为帧缓冲处理的操作。帧缓冲区是一个大数组,包含每个像素的颜色值,图形卡每秒向显示设备传送50-100次帧缓冲器。在此阶段执行的操作之一称为深度缓冲,它通过隐藏部分对象,以一定角度计算3D空间的2D视图。创建最终场景后,图形管线将图像传输到帧缓冲区。
以上就是图形处理器渲染复杂游戏,甚至是最小化或最大化窗口等标准操作的方式。渲染被定义为通过根据对象、规则和视觉效果处理场景的高级描述,以像素为单位生成场景的过程。渲染过程本质上涉及很多线性代数运算,包含对象旋转或平移都等矩阵运算。这些操作处理大量浮点值,并且本质上是并行的。
19.9.1.3 高性能计算与图形计算的融合
到了90年代末,计算机图形学领域迅速发展。计算机游戏、桌面视觉效果和先进的工程软件激增,需要复杂的计算机图形硬件加速器。因此,设计师越来越需要创造更生动的场景和更逼真的物体。我们可以比较80年代后期制作的动画电影和今天的好莱坞电影,今天的动画电影有非常逼真的人物,面部表情非常细致。多亏了图形硬件,所有这些都成为可能。为了创造这种身临其境的体验,有必要在图形处理器中增加很大程度的灵活性,以结合不同类型的视觉效果。因此,图形处理器设计者将处理器的许多内部部件暴露给低级软件,并允许程序员更灵活地使用处理器。一组名为着色器的程序诞生于2000年初,它们允许程序员创建灵活的片段和像素处理例程。
到2006年,主要GPU供应商已经认识到图形管道也可以用于通用计算,例如大量数值化的科学代码在概念上类似于片元或像素处理操作。我们如果允许常规用户程序访问图形处理器以执行其任务,就可以在图形处理器上运行大量科学程序。为了响应这一要求,NVIDIA发布了CUDA API,允许C程序员用C语言编写代码,并在图形处理器上运行,GPGPU(通用GPU)一词就此诞生了。
GPGPU代表通用图形处理单元,本质上是一个图形处理器,允许普通用户在其上编写和运行代码。用户通常使用专用语言或标准语言的扩展来生成与GPGPU兼容的代码。
后面将讨论NVIDIA Tesla GPU架构的设计,具体来说,将讨论GeForce 8800 GPU的设计。GPU最快的部分(核心)通常工作在1.5GHz或更高,其他部件的工作频率为600 MHz、750 MHz或以上。
19.9.2 GPU系统架构
当今常用的GPU系统架构有几种,下面将阐述它们的系统配置、GPU功能和服务、标准编程接口以及基本的GPU内部架构。
19.9.2.1 异构CPU–GPU系统架构
使用GPU和CPU的异构计算机系统架构可以通过两个主要特征在高层次上描述:第一,使用了多少功能子系统和/或芯片,以及它们的互连技术和拓扑结构;第二,哪些内存子系统可用于这些功能子系统。
下图显示了大约1990年遗留PC的高级结构图。北桥包含连接CPU、内存和PCI总线的高带宽接口,南桥包含传统的接口和设备:ISA总线(音频、LAN)、中断控制器;DMA控制器;时间/计数器。在该系统中,显示器由一个简单的帧缓冲子系统驱动,该子系统被称为VGA(视频图形阵列),它连接到PCI总线。具有内置处理元件(GPU)的图形子系统在1990年的PC环境中并不存在。
下图说明了目前常用的两种配置。它们的特点是具有各自存储器子系统的独立GPU(离散GPU)和CPU。在图a中,对于Intel CPU,GPU通过16通道PCI Express 2.0链路连接,以提供峰值16 GB/s传输速率(每个方向的峰值为8 GB/s)。类似地,在图b中,对于AMD CPU,GPU也通过具有相同可用带宽的PCI Express连接到芯片组。在这两种情况下,GPU和CPU可以访问彼此的内存,尽管可用带宽比它们访问更直接连接的内存的带宽要少。在AMD系统的情况下,北桥或存储器控制器与CPU集成在同一芯片中。
PCI Express(PCIe):使用点对点链路的标准系统I/O互连,链路具有可配置的通道数和带宽。
统一内存架构(unified memory architecture,UMA):CPU和GPU共享公共系统内存的系统架构。
这些系统上的一种低成本变体,即统一内存架构系统,仅使用CPU系统内存,而省略了系统中的GPU内存。这些系统具有相对较低的性能GPU,因为它们实现的性能受到可用系统内存带宽和增加的内存访问延迟的限制,而专用GPU内存提供高带宽和低延迟。
高性能系统变体使用多个连接的GPU,通常两到四个并行工作,其显示器呈菊花链,如NVIDIA SLI(可扩展链接互连)多GPU系统,专为高性能游戏和工作站而设计。
下一个系统类别将GPU与北桥(Intel)或芯片组(AMD)集成在一起,无论有无专用图形内存。
前述章节解释了缓存如何在共享地址空间中保持一致性。对于CPU和GPU,有多个地址空间,GPU可以使用由GPU上的MMU转换的虚拟地址访问自己的物理本地内存和CPU系统的物理内存。操作系统内核管理GPU的页表,可以使用一致或非一致的PCI Express事务访问系统物理页面,取决于GPU页面表中的属性。CPU可以通过PCI Express地址空间中的地址范围(也称为开口,aperture)访问GPU的本地内存。
诸如Sony PlayStation 3和Microsoft Xbox 360的控制台系统类似于前面描述的PC系统架构,控制台系统设计为在使用寿命长达五年或更长的时间内提供相同的性能和功能。在此期间,可以多次重新实现系统以开发更先进的硅制造工艺,从而以更低的成本提供恒定的能力。控制台系统不需要像PC系统那样扩展和升级其子系统,因此主要的内部系统总线倾向于定制而非标准化。
在如今的PC中,GPU通过PCI Express连接到CPU,前几代使用AGP。图形应用程序调用OpenGL或Direct3DAPI函数,将GPU用作协处理器,API通过为特定GPU优化的图形设备驱动程序向GPU发送命令、程序和数据。
AGP:原始PCI I/O总线的扩展版本,为单个卡插槽提供了高达原始PCI总线八倍的带宽。其主要目的是将图形子系统连接到PC系统中。
19.9.2.2 基础统一GPU架构
统一GPU架构基于许多可编程处理器的并行阵列。它们将顶点、几何体和像素着色器处理和并行计算统一在同一处理器上,与早期GPU不同,早期GPU具有专用于每种处理类型的单独处理器。可编程处理器阵列与固定功能处理器紧密集成,用于纹理过滤、光栅化、光栅操作、抗锯齿、压缩、解压缩、显示、视频解码和高清视频处理。尽管固定功能处理器在受面积、成本或功率预算限制的绝对性能方面明显优于更一般的可编程处理器,本小节重点介绍可编程处理器。
与多核CPU相比,多核GPU具有不同的架构设计点,其重点是在多个处理器核上高效地执行多个并行线程。通过使用许多更简单的内核并优化线程组之间的数据并行行为,每个芯片的晶体管预算更多地用于计算,而更少地用于片上缓存和开销。
统一的GPU处理器阵列包含许多处理器核心,通常组织为多线程多处理器。下图显示了具有112个流处理器(SP)核心阵列的GPU,这些核心被组织为14个多线程流多处理器(SM)。每个SP核心都是高度多线程的,在硬件中管理96个并发线程及其状态。处理器通过互连网络与四个64位宽的DRAM分区连接,每个SM有八个SP核、两个特殊功能单元(SFU)、指令和常量缓存、一个多线程指令单元和一个共享内存。这是NVIDIA GeForce 8800实现的基本Tesla架构,具有统一的架构,其中用于顶点、几何和像素着色的传统图形程序在统一的SM及其SP内核上运行,计算程序在相同的处理器上运行。
通过缩放多处理器的数量和内存分区的数量,处理器阵列架构可扩展到更小和更大的GPU配置。上图显示了共享纹理单元和纹理L1缓存的两个SM的七个集群,纹理单元将过滤后的结果传递给SM,并将一组坐标转换为纹理图。由于连续纹理请求的支持过滤器区域经常重叠,因此小型流式L1纹理缓存可有效减少对内存系统的请求数量。处理器阵列通过GPU范围的互连网络与光栅操作处理器(ROP)、二级纹理缓存、外部DRAM存储器和系统存储器连接。处理器的数量和内存的数量可以进行扩展,以针对不同的性能和市场细分设计平衡的GPU系统。
下图显示了NVIDIA Fermi架构GPU的总体布局。如图所示,L2缓存位于16个SM(上下8个SM)的中心,每个SM由2个相邻列和16行矩形(GPU处理器核心)以及一列16个加载/存储单元和一列4个特殊功能单元(SFU)表示。SM模块的更详细图示如下下图所示。下图中SM头部和底部的矩形是寄存器和L1/共享内存所在的位置,6个DRAM I/O接口中的每一个都具有64位存储器接口(DRAM接口电路在最外侧的左侧和右侧以深蓝色矩形显示)。因此,总体而言,GPU的GDDR5(图形双倍数据速率,专为图形处理而设计的DDR存储器)DRAM具有384位接口,允许支持总计6 GB的SM片外存储器(即全局、固定、纹理和局部)。此外,下图所示为主机接口,可在GPU布局图的左侧找到,主机接口允许GPU和CPU之间的PCIe连接。最后,GigaThread全局调度器(位于主机接口旁边)负责将线程块分配给所有SM的warp调度器。
19.9.3 多线程多处理器架构
为了解决不同的市场细分,GPU实现了可扩展的多处理器数量,实际上GPU是由多处理器组成的多处理器,此外,每个多处理器都是高度多线程的,可以高效地执行许多细粒度顶点和像素着色器线程。一个高质量的基本GPU有两到四个多处理器,而游戏爱好者的GPU或计算平台有几十个。本节将介绍一个这样的多线程多处理器的架构,是前面描述的NVIDIA Tesla流式多处理器(SM)的简化版本。
为什么要使用多处理器,而不是几个独立的处理器?每个多处理器内的并行性提供了本地化的高性能,并支持细粒度并行编程模型的广泛多线程,线程块的各个线程在多处理器内一起执行以共享数据。这里描述的多线程多处理器设计在紧密耦合的架构中有八个标量处理器内核,最多执行512个线程。为了提高面积和功率效率,多处理器在八个处理器内核中共享大型复杂单元,包括指令缓存、多线程指令单元和共享内存RAM。
GPU处理器高度多线程,可实现以下几个目标:
- 覆盖DRAM内存加载和纹理提取的延迟
- 支持细粒度并行图形着色器编程模型
- 支持细粒度并行计算编程模型
- 将物理处理器虚拟化为线程和线程块,以提供透明的可扩展性
- 将并行编程模型简化为为一个线程编写串行程序
内存和纹理提取延迟可能需要数百个处理器时钟,因为GPU通常具有小型流缓存,而不像CPU这样的大型工作集缓存,提取请求通常需要完整的DRAM访问延迟加上互连和缓冲延迟。当一个线程等待加载或纹理获取完成时,多线程有助于利用有用的计算来覆盖延迟,处理器可以执行另一个线程。细粒度并行编程模型提供了数千个独立的线程,尽管单个线程的内存延迟很长,但这些线程仍能让许多处理器保持忙碌。
图形顶点或像素着色器程序是用于处理顶点或像素的单个线程的程序,类似地,CUDA程序是用于计算结果的单个线程的C程序。图形和计算程序实例化许多并行线程,以渲染复杂图像并计算大型结果数组。为了动态平衡移动顶点和像素着色器线程工作负载,每个多处理器同时执行多个不同的线程程序和不同类型的着色器程序。
为了支持图形着色语言的独立顶点、图元和像素编程模型以及CUDA C/C++的单线程编程模型,每个GPU线程都有自己的专用寄存器、专用每线程内存、程序计数器和线程执行状态,并且可以执行独立的代码路径。为了有效地执行数百个并发轻量级线程,GPU多处理器是硬件多线程的,在硬件中管理和执行数百个并行线程,而无需调度开销。线程块中的并发线程可以在一个屏障处与单个指令同步,轻量级线程创建、零开销线程调度和快速屏障同步有效地支持非常细粒度的并行性。
19.9.3.1 海量线程
GPU处理器高度多线程化,可实现以下几个目标:
- 覆盖DRAM内存加载和纹理提取的延迟。
- 支持细粒度并行图形着色器编程模型。
- 支持细粒度并行计算编程模型。
- 将物理处理器虚拟化为线程和线程块,以提供透明的可扩展性。
- 将并行编程模型简化为为一个线程编写串行程序。
内存和纹理提取延迟可能需要数百个处理器时钟,因为GPU通常具有小型流缓存,而不像CPU的大型工作集缓存。提取请求通常需要完整的DRAM访问延迟加上互连和缓冲延迟,当一个线程等待加载或纹理获取完成时,多线程有助于利用有用的计算来覆盖延迟,处理器可以执行另一个线程(下图)。细粒度并行编程模型提供了数千个独立的线程,尽管单个线程的内存延迟很长,但这些线程仍能让许多处理器保持忙碌。
GPU利用多个Context切换来覆盖内存访问延迟。
图形顶点或像素着色器程序是用于处理顶点或像素的单个线程的程序,类似地,CUDA程序是用于计算结果的单个线程的C程序,图形和计算程序实例化许多并行线程以渲染复杂的图像并计算大型结果数组。为了动态平衡移动顶点和像素着色器线程工作负载,每个多处理器同时执行多个不同的线程程序和不同类型的着色器程序。
为了支持图形着色语言的独立顶点、图元和像素编程模型以及CUDA C/C++的单线程编程模型,每个GPU线程都有自己的专用寄存器、专用逐线程内存、程序计数器和线程执行状态,并且可以执行独立的代码路径。为了有效地执行数百个并发轻量级线程,GPU多处理器是硬件多线程的,它在硬件中管理和执行数百个并行线程,而无需调度开销。线程块中的并发线程可以在一个屏障处与单个指令同步,轻量级线程创建、零开销线程调度和快速屏障同步有效地支持非常细粒度的并行性。
19.9.3.2 多处理器架构
统一的图形和计算多处理器执行顶点、几何体和像素片段着色器程序以及并行计算程序。如下图所示,示例多处理器由八个标量处理器(SP)内核组成,每个内核具有一个大型多线程寄存器文件(RF)、两个特殊功能单元(SFU)、一个多线程指令单元、一个指令缓存、一个只读常量缓存和一个共享内存。
具有八个标量处理器(SP)核的多线程多处理器。八个SP核每个都有一个大型多线程寄存器文件(RF),并共享一个指令缓存、多线程指令发布单元、常量缓存、两个特殊功能单元(SFU)、互连网络和一个多组共享内存。
16KB的共享内存保存图形数据缓冲区和共享计算数据,声明为__shared__
的CUDA变量驻留在共享内存中。为了通过多处理器多次映射逻辑图形管道工作负载,顶点、几何体和像素线程具有独立的输入和输出缓冲区,工作负载的到达和离开与线程执行无关。
每个SP核心包含执行大多数指令的标量整数和浮点算术单元。SP是硬件多线程的,最多支持64个线程。每个流水线SP内核每时钟每个线程执行一个标量指令,在不同的GPU产品中,其范围从1.2 GHz到1.6 GHz。每个SP核心都有一个1024个通用32位寄存器的大RF,在其分配的线程之间进行分区。程序声明其寄存器需求,通常每个线程16到64个标量32位寄存器。SP可以同时运行使用少量寄存器的多个线程或使用更多寄存器的更少线程,编译器优化寄存器分配,以平衡溢出寄存器的成本与更少线程的成本。像素着色器程序通常使用16个或更少的寄存器,使每个SP能够运行多达64个像素着色器线程,以覆盖长延迟纹理提取。编译的CUDA程序通常每个线程需要32个寄存器,将每个SP限制为32个线程,限制了该示例多处理器上的内核程序每个线程块只能有256个线程,而不是最多512个线程。
流水线SFU执行线程指令,这些指令计算特殊函数,并从原始顶点属性插值像素属性,可以与SP上的指令同时执行。
多处理器通过纹理接口在纹理单元上执行纹理提取指令,并使用内存接口执行外部内存加载、存储和原子访问指令,这些指令可以与SP上的指令同时执行。共享内存访问使用SP处理器和共享内存组之间的低延迟互连网络。
19.9.3.3 单指令多线程(SIMT)
为了高效地管理和执行运行多个不同程序的数百个线程,多处理器采用了单指令多线程(SIMT)架构,它在称为warp的并行线程组中创建、管理、调度和执行并发线程。“warp”一词起源于第一种平行线技术——编织,下图中的照片显示了织机上出现的平行线的warp,此示例多处理器使用32个线程的SIMT warp大小,在四个时钟上在八个SP核中的每一个中执行四个线程。Tesla SM多处理器还使用32个并行线程的warp大小,每个SP内核执行四个线程,以提高大量像素线程和计算线程的效率。线程块由一个或多个warp组成。
SIMT多线程warp调度。调度器选择一个准备好的warp,并向组成warp的并行线程同步发出指令。因为warp是独立的,所以调度器每次都可以选择不同的warp。
单指令多线程(single-instruction multiple-thread,SIMT):一种并行地将一条指令应用于多个独立线程的处理器架构。
经线(warp):在SIMT体系结构中一起执行同一指令的一组并行线程。
此示例SIMT多处理器管理一个包含16个warp的池,总共512个线程。组成warp的单个并行线程是相同的类型,并在相同的程序地址一起开始,但在其他情况下可以自由分支并独立执行。在每次指令发出时,SIMT多线程指令单元选择一个准备好执行其下一条指令的warp,然后将该指令发出给该warp的活动线程。SIMT指令被同步广播到warp的活动并行线程,由于独立的分支或预测,各个线程可能处于非活动状态。在该多处理器中,每个SP标量处理器内核使用四个时钟为一个warp的四个单独线程执行一条指令,反映了warp线程与内核的4:1比率。
SIMT处理器架构类似于单指令多数据(SIMD)设计,它将一条指令应用于多个数据通道,但不同之处在于,SIMT将一条命令并行应用于多条独立线程,而不仅仅是多条数据通道。用于SIMD处理器的指令一起控制多个数据通道的向量,而用于SIMT处理器的指令控制单个线程,并且SIMT指令单元向独立并行线程的warp发出指令以提高效率。SIMT处理器在运行时发现线程之间的数据级并行性,类似于超标量处理器在运行时间发现指令之间的指令级并行性。
当warp的所有线程采用相同的执行路径时,SIMT处理器实现了充分的效率和性能。如果warp的线程通过依赖于数据的条件分支分叉,则执行会对所采用的每个分支路径进行串行化,并且当所有路径完成时,线程会汇聚到同一执行路径。对于等长路径,发散的if-else代码块的效率为50%,多处理器使用分支同步堆栈来管理发散和聚合的独立线程。不同的warp以全速独立执行,而不管它们是执行公共的还是不相交的代码路径。因此,与早期GPU相比,SIMT GPU在分支代码上的效率和灵活性显著提高,因为它们的warp比现有GPU的SIMD宽度窄得多。
四元素预测向量核上的分支和非分支执行。每个元素执行在判断p上分支的十个操作着色器A。在情况B中,所有四个元素都采用无分支,没有发散,只需要六个执行步骤。在情况C中,元素1采用no分支,但其他三个元素采用yes分支。判断通过分别执行no和yes操作来处理这种差异,因此需要所有十个执行步骤。
与SIMD向量架构相比,SIMT使程序员能够为单个独立线程编写线程级并行代码,以及为许多协调线程编写数据并行代码。对于程序的正确性,程序员基本上可以忽略warp的SIMT执行属性,但通过注意代码很少需要warp中的线程来发散,可以实现显著的性能改进。实际上,这与传统代码中缓存线的作用类似:在设计正确性时可以安全地忽略缓存行大小,但在设计峰值性能时必须在代码结构中考虑缓存行大小。
19.9.3.4 SIMT Warp执行和发散
调度独立warp的SIMT方法比先前GPU架构的调度更灵活。warp包含相同类型的并行线程:顶点、几何体、像素或计算。像素片段着色器处理的基本单元是实现为四个像素着色器线程的2*2像素四边形,多处理器控制器将像素四边形打包为warp,它类似地将顶点和图元分组为warp,并将计算线程打包为warp,线程块包括一个或多个warp。SIMT设计在一个warp的并行线程之间有效地共享指令获取和发出单元,但需要一个完整的活动线程warp来获得充分的性能效率。
这种统一的多处理器同时调度和执行多个warp类型,允许它同时执行顶点和像素warp。它的warp调度器以低于处理器时钟速率的速度运行,因为每个处理器内核有四个线程通道。在每个调度周期中,它选择一个warp来执行SIMT warp指令,如上图所示。发出的warp指令在四个处理器吞吐量周期内作为四组八个线程执行,处理器流水线使用几个延迟时钟来完成每个指令。如果活动warp次数乘以每个warp的时钟数超过了管线延迟,程序员可以忽略管线延迟。对于该多处理器,八个warp的循环调度在同一个warp的连续指令之间有32个周期。如果程序可以保持每个多处理器256个线程处于活动状态,那么单个连续线程可以隐藏多达32个周期的指令延迟。然而,由于很少有活动warp,处理器管线深度变得可见,可能会导致处理器停滞。
一个具有挑战性的设计问题是为不同warp程序和程序类型的动态混合实现零开销warp调度。指令调度程序必须每四个时钟选择一个warp,以便每个线程每个时钟发出一条指令,相当于每个处理器内核1.0的IPC。因为warp是独立的,所以唯一的依赖关系是来自同一warp的顺序指令。调度器使用寄存器相关性记分板来限定活动线程准备好执行指令的warp,它会优先考虑所有这些准备好的warp,并为问题选择最高优先级的warp。优先级必须考虑warp类型、指令类型以及对所有活动warp公平的愿望。
19.9.3.5 管理线程和线程块
多处理器控制器和指令单元管理线程和线程块。控制器接受工作请求和输入数据,并仲裁对共享资源的访问,包括纹理单元、内存访问路径和I/O路径。对于图形工作负载,它同时创建和管理三种类型的图形线程:顶点、几何体和像素。每种图形工作类型都有独立的输入和输出路径。它将这些输入工作类型中的每一种累积并打包为执行同一线程程序的并行线程的SIMT warp,它分配一个自由的warp,为warp线程分配寄存器,并在多处理器中开始warp执行。每个程序都声明其每线程寄存器需求,只有当控制器可以为warp分配请求的寄存器计数时,控制器才启动warp。当warp的所有线程退出时,控制器将解开打包结果并释放warp寄存器和资源。
控制器创建协作线程阵列(cooperative thread array,CTA),将CUDA线程块实现为一个或多个并行线程warp,当它可以创建所有CTA warp并分配所有CTA资源时,它会创建CTA。除了线程和寄存器,CTA还需要分配共享内存和障碍。程序声明所需的容量,控制器等待,直到可以分配这些容量,然后启动CTA。随后,它以warp调度速率创建CTA warp,从而使CTA程序立即以完全的多处理器性能开始执行。控制器监控CTA的所有线程何时退出,并释放CTA共享资源及其warp资源。
协同线程阵列(cooperative thread array,CTA):一组并发线程,它们执行相同的线程程序,并可以协作计算结果。GPU CTA实现CUDA线程块。
19.9.3.6 线程指令
SP线程处理器为单个线程执行标量指令,与早期的GPU矢量指令架构不同,后者为每个顶点或像素着色器程序执行四个分量矢量指令。顶点程序通常计算(x,y,z,w)位置向量,而像素着色器程序计算(红、绿、蓝、Alpha)颜色向量。然而,着色器程序变得越来越长,越来越标量化,甚至很难完全占据传统GPU四分量矢量架构的两个组件。实际上,SIMT架构跨32个独立的像素线程进行并行化,而不是并行化一个像素内的四个矢量组件。CUDA C/C++程序主要具有每个线程的标量代码,以前的GPU使用向量打包(例如,组合工作的子向量以获得效率),但会使得调度硬件和编译器复杂化。标量指令更简单且编译器友好,纹理指令仍然基于向量,获取源坐标向量并返回过滤后的颜色向量。
为了支持具有不同二进制微指令格式的多个GPU,高级图形和计算语言编译器生成中间汇编程序级指令(例如Direct3D矢量指令或PTX标量指令),然后将其优化并转换为二进制GPU微指令。NVIDIA PTX(并行线程执行)指令集定义为编译器提供了稳定的目标ISA,并提供了几代GPU与不断发展的二进制微指令集架构的兼容性,优化器很容易将Direct3D矢量指令扩展为多个标量二进制微指令。尽管一些PTX指令扩展为多个二进制微指令,并且多个PTX指令可以折叠成一个二进制微命令,但PTX标量指令几乎可以用标量二进制微指令进行一对一转换。由于中间汇编程序级指令使用虚拟寄存器,优化器分析数据相关性并分配实际寄存器。优化器消除了死代码,在可行时将指令折叠在一起,并优化了SIMT分支的分叉点和聚合点。
指令集体系结构(ISA)
这里描述的线程ISA是Tesla架构PTX ISA的简化版本,是一个基于寄存器的标量指令集,包括浮点、整数、逻辑、转换、特殊函数、流控制、内存访问和纹理操作。下图列出了基本的PTX GPU线程指令,有关详细信息,请参阅NVIDIA PTX规范。
其指令格式为:
opcode.type d, a, b, c;
其中d是目标操作数,a、b、c是源操作数,.type是以下之一:
类型 | .type特定值 |
---|---|
无类型的位8、16、32和64位 | .b8、.b16、.b32、.b64 |
无符号整数8、16、32和64位 | .u8、.u16、.u22、.u64 |
有符号整数8、16、32和64位 | .s8、.s16、.s32、.s64 |
浮点16、32和64位 | .16、.f32、.f64 |
源操作数是寄存器中的标量32位或64位值、立即数或常量,判断操作数是1位布尔值。目的地是寄存器,存储到内存除外。指令是通过在它们前面加上@p或@!p、 其中p是判断寄存器。内存和纹理指令传输两到四个分量的标量或向量,总计最多128位。PTX指令指定一个线程的行为。
PTX算术指令对32位和64位浮点、有符号整数和无符号整数类型进行操作。当前GPU支持64位双精度浮点,PTX 64位整数和逻辑指令被转换为两个或多个执行32位操作的二进制微指令,GPU特殊功能指令仅限于32位浮点。线程控制流指令包括条件分支、函数调用和返回、线程退出和bar.sync(屏障同步)。条件分支指令@p bra target使用判断寄存器p(或!p)来确定线程是否执行分支,该判断寄存器p先前由比较和设置判断setp指令设置,其他指令也可以基于判断寄存器为真或假。
19.9.3.7 内存访问指令
tex指令通过纹理子系统从内存中的1D、2D和3D纹理阵列中提取并过滤纹理样本。纹理提取通常使用插值浮点坐标来处理纹理。一旦图形像素着色器线程计算其像素片段颜色,光栅操作处理器将其与指定(x,y)像素位置的像素颜色混合,并将最终颜色写入内存。
为了支持计算和C/C++语言需求,Tesla PTX ISA实现了内存加载/存储指令。它使用整数字节寻址和寄存器加偏移地址算法,以促进常规编译器代码优化。内存加载/存储指令在处理器中很常见,但在Tesla架构GPU中是一项重要的新功能,因为以前的GPU只提供图形API所需的纹理和像素访问。
对于计算,加载/存储指令访问实现第B.3节中相应CUDA存储空间的三个读/写存储空间:
- 逐线程专用可寻址临时数据的局部内存(在外部DRAM中实现)。
- 共享内存,用于低延迟访问同一个CTA/线程块中协作线程共享的数据(在片上SRAM中实现)。
- 由计算应用程序的所有线程共享的大型数据集的全局存储器(在外部DRAM中实现)。
内存加载/存储指令ld.global、st.global、ld.shared、st.shared、ld.local和st.local分别访问全局、共享和局部内存空间。计算程序使用快速屏障同步指令bar.sync以同步CTA/线程块内通过共享和全局内存彼此通信的线程。
为了提高内存带宽并减少开销,当地址落在同一块中并满足对齐标准时,局部和全局加载/存储指令将来自同一SIMT warp的单个并行线程请求合并为单个内存块请求。与来自单个线程的单独请求相比,合并内存请求可显著提高性能。多处理器的大量线程数,加上对许多未完成的负载请求的支持,有助于覆盖负载,从而使用外部DRAM中实现的局部和全局内存的延迟。
Tesla架构GPU还通过atom.op.u32指令在内存上提供高效的原子内存操作,包括整数操作add、min、max、and、or、xor、exchange和cas(比较和交换)操作,有助于并行缩减和并行数据结构管理。
19.9.3.8 线程通信的屏障同步
快速屏障同步允许CUDA程序通过简单调用__syncthreads(),通过共享内存和全局内存频繁通信,作为每个线程间通信步骤的一部分。同步内建函数生成单个bar.sync指令,但在每个CUDA线程块最多512个线程之间实现快速屏障同步是一个挑战。
将线程分组为32个线程的SIMT warp将同步难度降低了32倍。线程在SIMT线程调度程序中的一个屏障处等待,因此它们在等待时不会消耗任何处理器周期。当线程执行一条bar.sync指令,它递增屏障的线程到达计数器,调度器将线程标记为在屏障处等待。一旦所有CTA线程到达,屏障计数器与预期的终端计数相匹配,调度器释放在屏障处等待的所有线程并恢复执行线程。
19.9.3.9 流式多处理器(SM)
纹理/处理器集群。
上图显示了具有两个SM的TPC的结构。几何控制器在单个核上协调顶点和形状处理,它从内存层次结构中引入顶点数据,指导内核处理它们,然后协调将输出存储到内存层次结构的过程。此外,它还有助于将输出转发到下一个处理阶段。SMC(SM控制器)调度对外部资源的请求,例如,SM中的多个内核可能希望写入DRAM内存或访问纹理单元。在这种情况下,SMC对请求进行仲裁。
现在看看SM的结构。每个SM都有一个I缓存(指令缓存)、一个C缓存(常量缓存)和一个用于多线程工作负载的内置线程调度器(MT Issue Unit)。8个SP核可以访问嵌入在SM中的共享存储器单元,以便在它们之间进行通信。SP核心具有符合IEEE 754的浮点ALU,可以执行常规浮点运算,如加法、减法和乘法。它还支持称为乘法加法的特殊指令,这在图形计算中是非常常见的,此指令计算表达式的值:a*b+c。与FP ALU一起,每个SP都有一个整数ALU,可以执行常规整数指令和逻辑指令,此外,SP核心可以执行内存指令和分支指令。与向量处理器类似,SP核心实现预测指令,意味着他们将执行槽专用于错误路径中的指令,尽管它们被nop指令取代。SP针对速度进行了优化,是整个GPU中速度最快的单元,因为它们实现了一个非常简单的类似RISC的指令集,它主要由基本指令组成。
为了计算更复杂的数学函数,例如超越函数或三角函数,每个SM中有两个特殊的函数单元(SFU)。SFU还具有专门的单元,用于插值片元内的颜色值,GPU使用此功能为每个三角形片段的内部着色。除了专用单元外,SFU还具有用于运行通用代码的常规整数/浮点ALU。
TPC中的两个SM共享一个纹理单元,纹理单元可以同时处理四个线程,并将光栅化后生成的所有三角形与与三角形关联的曲面纹理进行处理。纹理信息存储在纹理单元内的小缓存中,在缓存未命中时,纹理单元可以从相关的二级缓存或从主DRAM存储器获取数据。
现在讨论如何在GPU上执行计算。SM中的每个线程(映射到SP)可以访问逐线程局部内存(保存在外部DRAM上)、共享内存(在SM中的所有线程之间共享,并保存在芯片上)或全局DRAM内存。程序员可以明确指示GPU使用某种内存。
更详细的单个SM结构如下图所示。
单个SM架构。
上图右侧将NVIDIA费米体系结构分解为单个SM的基本组件,这些组件是:
- GPU处理器内核(共32个CUDA内核)。
- Warp调度程序和调度端口。
- 16个加载/存储单元。
- 四个SFU。
- 32k*32位寄存器。
- 共享内存和一级缓存(共64 kB)。
下面详细阐述SM内的各个部件。先阐述双warp调度器(dual warp scheduler)。
如前所述,GPU芯片上的GigaThread全局调度器单元将线程块分配给SM,然后双warp调度器将其处理的每个线程块分解为warp,其中warp是由32个线程组成的束,这些线程从相同的起始地址开始,其线程ID是连续的。一旦发出warp,每个线程都会有自己的指令地址计数器和寄存器集,以允许SM中每个线程的独立分支和执行。
GPU在处理尽可能多的warp以最大限度地利用CUDA内核时效率最高。如下图所示,当双warp调度器和指令调度单元能够每两个时钟周期发出两次warp(Fermi架构)时,SM硬件利用率将达到最大值。如下文所述,结构冲突是SM无法达到最大处理速率的主要原因,而片外内存访问延迟则更容易隐藏。
如果组件列不存在结构冲突,则每个划分的列由16个CUDA核心(*2)、16个加载/存储单元和4个SFU(上图)组成,每个时钟周期可以从两个warp调度器/调度单元中的每一个分配半个warp(16个线程)进行处理。结构冲突由有限的SFU、双精度乘法和分支引起,但是,warp调度程序有一个内置的记分板(scoreboard)来跟踪可用于执行的warp以及结构冲突,使得SM既能避免结构冲突,又能尽可能地隐藏芯片外内存访问延迟。
双warp调度器和指令调度单元运行示例。
因此,程序员必须将线程块大小设置为大于SM中CUDA内核的总数,但小于每个块允许的最大线程数,并确保线程块大小(在x和/或y维度)为32的倍数(warp大小),以实现SM的接近最佳利用率。
阐述完双warp调度器,再阐述CUDA核心。
NVIDIA GPU处理器内核也称为CUDA内核,在Fermi架构中,共有32个CUDA核专用于每个SM。每个CUDA核心都有两个独立的管线或数据路径:一个整数(INT)单元管线和一个浮点(FP)单元管线(见上上图),在一个时钟周期内只能使用这些数据路径中的一个。INT单元能够进行32位、64位和扩展精度的整数和逻辑/位运算,FP单元可以执行单精度FP运算,而双精度FP运算需要两个CUDA核。因此,与单精度FP线程相比,仅执行双精度FP操作的线程运行所需的时间是其两倍。通过在每个SM中包含专用的双精度单元以及大多数单精度单元,Kepler架构解决了双精度FP算法的性能影响。幸运的是,CUDA程序员隐藏了线程级FP单精度和双精度操作的管理,但程序员应该意识到使用基于所用GPU的两种精度类型之间可能产生的潜在性能影响。
Fermi架构为CUDA核心的FP单元增加了一项改进,从IEEE 754-1985浮点算术标准升级为IEEE 754-2008标准,是通过使用融合乘法加法(FMA)指令提高乘法加法指令(MAD)的精度来实现的。FMA指令对单精度和双精度算术都有效,Fermi架构仅在FMA指令末尾执行一次舍入,此举不仅提高了结果的准确性,而且执行FMA指令也被压缩到单处理器时钟周期中。因此,每个SM在一个处理器时钟周期内可以进行32次单精度或16次双精度FMA操作。
其它部件说明如下:
-
特殊函数单元(special function unit):每个SM有四个SFU。SFU在一个时钟周期内执行超越运算,如余弦、正弦、倒数和平方根。由于一个SM中只有4个SFU,而一个warp中只有一条指令的32个并行线程,因此完成一个需要SFU的warp需要8个时钟周期,但CUDA处理器以及加载和存储单元仍然可以同时使用。
-
加载和存储单位:SM的16个加载和存储单元中的每一个计算每个时钟周期单个线程的源地址和目标地址,这些地址用于线程希望写入数据或从中读取数据的缓存或DRAM。
-
寄存器、共享内存和L1缓存:每个SM都有自己的(片上)专用寄存器集和共享内存/l1缓存块。关于低延迟片上内存的详细信息和优点如下表。
内存类型 相对访问时间 访问类型 范围 数据生存期 寄存器 最快,芯片内 R/W 单线程 线程 共享 快,芯片内 R/W 块上的所有线程 块 局部 比共享和寄存器慢100到150倍,芯片外 R/W 单线程 线程 全局 比共享和寄存器慢100到150倍,芯片外 R/W 所有线程和主机 应用程序 固定 比共享和寄存器慢100到150倍,芯片外 R 所有线程和主机 应用程序 纹理 比共享和寄存器慢100到150倍,芯片外 R 所有线程和主机 应用程序
尽管Fermi架构每个SM有一个令人印象深刻的32k x 32位寄存器,但每个线程最多分配64x32位的寄存器,如CUDA计算能力2.x版所定义的,这是每个SM允许的最大活动warp数以及每个SM的寄存器数的函数。如上表所示,寄存器和共享内存的最快访问时间只有几纳秒(ns)。如果有任何临时寄存器溢出,数据将首先移动到L1缓存,然后再发送到L2缓存,然后是长访问延迟本地内存(见下图a)。使用一级缓存有助于防止发生数据读/写冲突,因此分配给线程的寄存器中的数据的寿命仅与线程的寿命相同。
Fermi内存架构。
与当代多核微处理器(如CPU)相比,专用于SM的GPU处理器核心的可寻址片上共享内存是一种独特的配置,这些当代架构具有专用的片上L1缓存和每个内核一组寄存器,但它们通常没有片上可寻址内存。相反,专用内存管理硬件在没有程序员控制的情况下调节高速缓存和主内存之间的数据移动,与GPU架构有很大不同。
共享内存被添加到GPU架构中,专门用于辅助GPGPU应用程序。优化共享内存的使用可以通过消除对片外内存的不必要的长延迟访问,显著提高GPGPU应用程序的速度和性能。尽管每个SM的共享内存大小很小(最大配置为48 kB),但它的访问延迟非常低,比全局内存少100到150倍(见上表)。因此,共享内存可以通过三种主要方式加速并行处理任务:
- 块的所有线程多次重复使用共享内存数据(如用于矩阵-矩阵乘法的数据块)。
- 使用块的选择线程(基于特定ID)将数据从全局内存传输到共享内存,从而消除了对相同内存位置的冗余读取和写入。
- 如果可能,用户可以通过确保访问被合并来优化对全局内存的数据访问。
所有这些点也有助于减少片外内存带宽限制问题。SM共享内存中数据的生命周期与在其上处理的线程块的生命周期一样长。因此,一旦块的所有线程完成,SM共享内存中的数据就不再有效。
尽管共享内存的使用将提供最佳运行时间,但在某些应用程序中,在编程阶段内存访问是未知的,拥有更多可用的L1缓存(最大设置为48 kB)将获得最佳结果。此外,L1缓存有助于防止寄存器溢出,而不是直接进入本地(片外)DRAM内存。两级缓存层次结构每个SM一个L1缓存,以及跨芯片、SM共享的L2缓存提供了与传统多核微处理器相同的好处。
我们需要认识到,在GPU编程中,理解内存类型具有举足轻重的作用。
程序员必须了解各种GPU内存的细微差别,特别是每种内存类型的可用大小、相对访问时间和可访问性限制,以使用CUDA进行正确高效的代码开发。GPGPU编程所需的方法与针对CPU的程序开发方法大不相同,其中所使用的特定数据存储硬件(文件I/O除外)对程序员来说是隐藏的。
例如,在GPU架构中,分配给CUDA内核的每个线程都有自己的寄存器集,因此一个线程无法访问另一个线程的寄存器,无论是否在同一个SM中。特定SM中的线程可以相互协作(通过数据共享)的唯一方式是通过共享内存(下图),通常通过程序员仅分配SM的某些线程来写入其共享内存的特定位置来实现,从而防止写入冲突或浪费周期(例如许多线程从全局内存读取相同的数据并将其写入相同的共享内存地址)。在特定SM的所有线程被允许从刚刚写入的共享内存中读取之前,需要对该SM的所有的线程进行同步,以防止写入后读取(RAW)数据冲突。
GPU基本架构的CUDA表示。
19.9.3.10 流处理器(SP)
多线程流处理器(SP)核心是多处理器中的主要线程指令处理器,其寄存器文件(RF)为多达64个线程提供1024个标量32位寄存器。它执行所有基本的浮点运算,包括add.f32、mul.f32、mad.f32(浮动乘加)、min.f32, max.f32和setp.f32(浮动比较和设置判断)。浮点加法和乘法运算与IEEE 754标准兼容,适用于单精度FP数,包括非整数(NaN)和无穷大值。SP核心还实现了所有32位和64位整数运算、比较、转换和逻辑PTX指令。
浮点加法和乘法运算采用IEEE舍入调,甚至作为默认舍入模式。mad.f32浮点乘法加法运算执行带截断的乘法,然后执行带舍入到最接近偶数的加法。SP将输入非正规操作数刷新为符号保留零,舍入后,将目标输出指数范围下溢的结果刷新为符号保留零。
19.9.3.11 特殊功能单元(SFU)
某些线程指令可以与SP上执行的其他线程指令同时在SFU上执行。SFU实现了特殊函数指令,该指令计算32位浮点逼近的倒数、倒数平方根和关键超越函数,它还为像素着色器实现32位浮点平面属性插值,提供颜色、深度和纹理坐标等属性的精确插值。
每个流水线SFU每个周期生成一个32位浮点特殊函数结果,每个多处理器的两个SFU以八个SP的简单指令速率的四分之一执行特殊功能指令。SFU还与八个SP同时执行mul.f32乘法指令,将具有适当指令混合的线程的峰值计算率提高到50%。
对于功能评估,Tesla架构SFU采用基于增强的最小极大近似的二次插值来逼近倒数、倒数平方、log2xlog2x、2x和sin/cos函数,函数计算的精度范围从22到24个尾数位。
19.9.3.12 与其他多处理器相比
与x86 SSE等SIMD矢量体系结构相比,SIMT多处理器可以独立执行单个线程,而不是总是在同步组中一起执行它们。SIMT硬件在独立线程之间找到数据并行性,而SIMD硬件要求软件在每个向量指令中明确表示数据并行性。当线程采用相同的执行路径时,SIMT机器同步执行32个线程的warp,但当它们分开时,可以独立执行每个线程。这一优势非常明显,因为SIMT程序和指令只描述单个独立线程的行为,而不是四个或更多数据通道的SIMD数据向量。然而,SIMT多处理器具有类似于SIMD的效率,将一个指令单元的面积和成本扩展到32个warp线程和8个流处理器核心。SIMT提供了SIMD的性能和多线程的生产力,避免了为边缘条件和部分发散显式编码SIMD向量的需要。
SIMT多处理器的开销很小,因为它是带有硬件屏障同步的硬件多线程,允许图形着色器和CUDA线程表达非常细粒度的并行性。图形和CUDA程序使用线程来表示每线程程序中的细粒度数据并行性,而不是强迫程序员将其表示为SIMD向量指令。与矢量代码相比,开发标量单线程代码更简单、更高效,SIMT多处理器以类似SIMD的效率执行代码。
将八个流处理器核心紧密耦合到一个多处理器中,然后实现可扩展数量的多处理器,从而形成由多处理器组成的两级多处理器。CUDA编程模型通过为细粒度并行计算提供单个线程,并为粗粒度并行操作提供线程块网格,从而利用了两级层次结构,同一线程程序可以提供细粒度和粗粒度操作。相反,具有SIMD向量指令的CPU必须使用两种不同的编程模型来提供细粒度和粗粒度操作:不同内核上的粗粒度并行线程,以及用于细粒度数据并行的SIMD向量。
19.9.3.13 多线程多处理器结论
基于Tesla架构的示例GPU多处理器是高度多线程的,同时执行多达512个轻量级线程,以支持细粒度像素着色器和CUDA线程。它使用了SIMD架构和多线程的一种变体,称为SIMT(单指令多线程),以有效地将一条指令广播到32个并行线程的warp中,同时允许每个线程独立地分支和执行。每个线程在八个流处理器(SP)内核之一上执行其指令流,这些内核最多有64个线程。
PTX ISA是一种基于寄存器的加载/存储标量ISA,用于描述单个线程的执行。由于PTX指令被优化并转换为特定GPU的二进制微指令,因此硬件指令可以快速发展,而不会中断生成PTX指令的编译器和软件工具。
19.9.3.14 分块渲染(Binned Rendering)
我们通常将光栅化定义为将屏幕坐标几何图元直接转换为像素片段的过程,但是,也可以将光栅化到更大的屏幕区域,例如n×n像素块。GeForce 9800 GTX光栅化器就是一个例子,它输出2×2个四边形片段以简化纹理重映射计算。分块渲染(Binned Rendering,亦称装箱渲染)将光栅化分为两个阶段:第一阶段输出中等大小的分块片段,每个片段对应于屏幕坐标中的8×8、16×16或32×32像素网格,随后是第二阶段,该第二阶段将每个分块片段减少为像素片段。当然,平铺片段包括从屏幕坐标图元导出的信息,以便第二阶段光栅化可以产生正确的像素片段。
分块渲染实际上将整个渲染过程分为两个阶段,对应于光栅化的两个阶段。在第一阶段,通过分块光栅化处理场景,并将生成的分块片段分类到各个分格中,每个分格对应于每个屏幕分块。只有在第一阶段完成之后(即在生成了整个场景的分块片段并将其分类到箱子中之后),第二阶段才开始。在第二阶段,每个bin都被单独处理,直到完成,产生一个n×n的像素块,并将其存储在帧缓冲区中。
分块渲染有几个吸引人的特性:
- 局部内存:帧缓冲区数据一致性的绝对保证,仅访问分块中的像素,允许在局部内存中处理像素,而不是从主内存缓存。功耗和主内存周期都可节省,使得分块渲染成为移动设备的一个有吸引力的解决方案。
- 全场景抗锯齿:回想一下,多采样抗锯齿需要在每个像素存储多个颜色和深度采样。由于通过增加采样数提高了质量,因此当渲染到整个帧缓冲区时,存储和带宽都变得非常昂贵,但当渲染仅限于一小块像素时,它们仍然很经济。甚至更高级的渲染算法,如透明表面的顺序无关渲染,都可以通过巧妙地使用局部内存来支持。
- 延迟着色:将渲染限制在一小块像素上解决了延迟着色的关键限制:需要过多的内存存储和带宽,以及与多样本抗锯齿不兼容。
分块渲染的优点是引人注目的,但目前还没有PC级GPU实现它。最根本的原因是,分块渲染与管线Direct3D和OpenGL架构的差异太大,我们说抽象距离太大。通常,过度的抽象距离会导致产品具有混杂的性能特征(预期快的操作是慢的,预期慢的操作是快的)或与指定操作的细微偏差。遇到的实际问题包括:
- 过度延迟:以前的分块渲染系统,如北卡罗来纳大学教堂山分校开发的PixelPlanes 5系统,增加了全帧延迟时间。
- 较差的多pass操作:Direct3D和OpenGL鼓励先进的多pass渲染技术,在分块实现中,每个最终帧需要多次两遍操作。例如,通过1)渲染在反射中可见的场景,2)将该图像加载为纹理,3)使用适当扭曲的纹理图像渲染表面来渲染来自表面的反射。一些分块渲染系统无法支持此类操作,其他的虽支持但表现不佳。
- 无边界内存需求:虽然分块渲染将像素存储限制为单个块所需的存储,但分块本身所需的内存会随着场景复杂性而增加。OpenGL和Direct3D都没有场景复杂度限制,因此完全确认的实现需要无限的内存(显然不可能),或者必须引入复杂度来处理有限块存储不足的情况。
这些复杂性已经足以将binned渲染排除在主流PC GPU之外。但是最近的实现趋势,特别是使用时间共享的单个计算引擎来实现所有管线着色阶段,可能会克服一些困难。
19.9.4 并行内存系统
在GPU本身之外,内存子系统是图形系统性能的最重要决定因素,图形工作负载需要非常高的内存传输速率。像素写入和混合(读取-修改-写入)操作、深度缓冲区读取和写入、纹理贴图读取,以及命令和对象顶点和属性数据读取,构成了大部分内存流量。
现代GPU是高度并行的,例如GeForce 8800可以在600 MHz下处理每个时钟32个像素,每个像素通常需要4字节像素的颜色读写和深度读写。通常读取平均两个或三个四字节的纹素,以生成像素的颜色,对于典型情况,每个时钟需要28字节乘以32像素=896字节,显然对内存系统的带宽需求是巨大的。
为了满足这些要求,GPU内存系统具有以下特点:
- 它们很宽,意味着GPU和它的内存设备之间有大量的引脚来传输数据,而内存阵列本身包括许多DRAM芯片来提供全部的数据总线宽度。
- 它们速度很快,意味着使用积极的信令技术来最大化每引脚的数据速率(比特/秒)。
- GPU寻求使用每个可用周期来向或从内存阵列传输数据。为了实现这一点,GPU特别不以最小化内存系统的延迟为目标。高吞吐量(利用效率)和短延迟从根本上来说是相冲突的。
- 使用的压缩技术既有程序员必须意识到的有损压缩技术,也有应用程序不可见的无损压缩技术。
- 缓存和工作合并结构用于减少所需的片外流量,并确保尽可能充分地使用移动数据所花费的周期。
19.9.4.1 显存结构
虽然DRAM通常被视为一个扁平的字节数组,但其内部结构要复杂得多。对于像GPU这样的高性能应用程序,非常有必要深入地理解它。从下往上大致看,VRAM由以下部分组成:
-
R行乘以C列的内存平面(memory plane),每个单元为一位。
-
由32、64或128个并行使用的内存平面组成的内存体(memory bank)——这些平面通常分布在多个芯片上,其中一个芯片包含16或32个内存平面。bank中的所有页面都连接到行寻址系统(列也是如此),并且这些页面由命令信号和每行/列的地址控制。bank中的行和列越多,地址中需要使用的位就越多。
-
由若干个[2、4或8]个memory bank连接在一起并由地址位选择的内存排(memory rank)——给定内存平面的所有memory bank位于同一芯片中。
-
由一个或两个连接在一起并由芯片选择线选择的memory rank组成的内存子分区(memory subpartition)——rank的行为类似于bank,但不必具有统一的几何结构,而是在单独的芯片中。
-
由一个或两个稍微独立的memory subpartition组成了内存分区(memory partition)。
-
整个VRAM由几个[1-8]个memory partition组成。
以上数量会因不同的GPU架构和家族而不同。
简化GDDR3存储器电路的结构图。为了提高清晰度,实际存储容量(十亿位)减少到256位,实现为16个16位块(也称为行)的阵列。到达块左边缘的红色箭头表示控制路径,而到达块顶部和底部的蓝色箭头表示数据路径。
DRAM最基本的单元是内存平面,它是按所谓的列和行组织的二维位数组:
column
row 0 1 2 3 4 5 6 7
0 X X X X X X X X
1 X X X X X X X X
2 X X X X X X X X
3 X X X X X X X X
4 X X X X X X X X
5 X X X X X X X X
6 X X X X X X X X
7 X X X X X X X X
buf X X X X X X X X
内存平面包含一个缓冲区,该缓冲区可容纳整个行。在内部,DRAM通过缓冲区以行为单位进行读/写。因此有几个后果:
- 在对某个位进行操作之前,必须将其行加载到缓冲区中,会很慢。
- 处理完一行后,需要将其写回内存数组,也很慢。
- 因此,访问新行的速度很慢,如果已经有一个活动行,访问速度甚至更慢。
- 在一段不活动时间后,抢先关闭一行通常很有用——这种操作称为precharging(预充电?)一个bank。
- 但是,可以快速访问同一行中的不同列。
由于加载列地址本身比实际访问活动缓冲区中的位花费更多的时间,所以DRAM是以突发方式访问的,即对活动行中1-8个相邻位的一系列访问,通常突发中的所有位都必须位于单个对齐的8位组中。内存平面中的行和列的数量始终是2的幂,并通过行选择和列选择位的计数来衡量[即行/列计数的log2],通常有8-10列位和10-14行位。内存平面被组织在bank中,bank由两个内存平面的幂组成。内存平面是并行连接的,共享地址和控制线,只有数据/数据启用线是分开的。这有效地使内存bank类似于由32位/64位/128位内存单元组成的内存平面,而不是单个位——适用于平面的所有规则仍然适用于bank,但操作的单元比位大。单个存储芯片通常包含16或32个存储平面,用于单个bank,因此多个芯片通常连接在一起以形成更宽的bank。
一个内存芯片包含多个[2、4或8]个bank,使用相同的数据线,并通过bank选择线进行多路复用。虽然在bank之间切换比在一行中的列之间切换要慢一些,但要比在同一bank中的行之间切换快得多。因此,一个内存bank由(MEMORY_CELL_SIZE / MEMORY_CELL_SIZE_PER_CHIP)内存芯片组成。一个或两个通过公共线(包括数据)连接的内存列,芯片选择线除外,构成内存子分区。在rank之间切换与在bank中的列组之间切换具有基本相同的性能后果,唯一的区别是物理实现和为每个rank使用不同数量行选择位的可能性(尽管列计数和列计数必须匹配)。存在多个bank/rank的后果:
- 确保一起访问的数据要么属于同一行,要么属于不同的bank,这一点很重要(以避免行切换)。
- 分块内存布局的设计使分块大致对应于一行,相邻的分块从不共享一个bank。
内存子分区在GPU上有自己的DRAM控制器。1或2个子分区构成一个内存分区,它是一个相当独立的实体,具有自己的内存访问队列、自己的ZROP和CROP单元,以及更高版本卡上的二级缓存。所有内存分区与crossbar逻辑一起构成了GPU的整个VRAM逻辑,分区中的所有子分区必须进行相同的配置,GPU中的分区通常配置相同,但在较新的卡上则不是必需的。子分区/分区存在的后果:
- 与bank一样,可以使用不同的分区来避免相关数据的行冲突。
- 与bank不同,如果(子)分区没有得到同等利用,带宽就会受到影响。因此,负载平衡非常重要。
虽然内存寻址高度依赖于GPU系列,但这里概述了基本方法。内存地址的位按顺序分配给:
- 识别内存单元中的字节,因为无论如何都必须访问整个单元。
- 多个列选择位,以允许突发(burst)。
- 分区/子分区选择-以低位进行,以确保良好的负载平衡,但不能太低,以便在单个分区中保留相对较大的tile,以利于ROP。
- 剩余列选择位。
- 所有/大部分bank选择位,有时是排名选择位,以便相邻地址不会导致行冲突。
- 行位(row bit)。
- 剩余的bank位或rank位,有效地允许将VRAM拆分为两个区域,在其中一个区域放置颜色缓冲区,在另一个区域放置zeta缓冲区,这样它们之间就不会有行冲突。
GPU必须考虑DRAM的独特特性。DRAM芯片在内部被布置为多个(通常为四到八个)存储体(bank),其中每个bank包括2次幂数的行(通常为16384),并且每行包含2次幂位数的位(通常为8192)。DRAM对其控制处理器施加了各种时序要求,例如激活一行需要几十个周期,但一旦激活,该行内的位可以每四个时钟随机访问一个新的列地址。双倍数据速率(DDR)同步DRAM在接口时钟的上升沿(rising edge)和下降沿(falling edge)传输数据(下面两图),因此1GHz时钟DDR DRAM以每数据引脚每秒2千兆比特的速度传输数据。图形DDR DRAM通常有32个双向数据引脚,因此每个时钟可以从DRAM读取或写入8个字节。
单ank和双rank对比。
单速率、双速率、四速率对比图。
GPU内部有大量的内存流量生成器。逻辑图形管线的不同阶段都有自己的请求流:命令和顶点属性提取、着色器纹理提取和加载/存储,以及像素深度和颜色读写。在每个逻辑阶段,通常有多个独立的单元来提供并行吞吐量,都是独立的内存请求者。当在内存系统中查看时,有大量不相关的请求正在运行,是与DRAM优选的参考模式(pattern)的自然不匹配。一种解决方案是GPU的内存控制器为不同的DRAM组保持单独的流量堆,并等待特定DRAM行有足够的流量等待,然后激活该行并同时传输所有流量。请注意,累积未决请求虽然有利于DRAM行位置,从而有效地使用数据总线,但会导致较长的平均等待时间,正如请求者等待其他请求所看到的那样。设计必须注意,任何特定的请求都不会等待太长时间,否则一些处理单元可能会等待数据,最终导致相邻处理器闲置。
GPU内存子系统被布置为多个存储器分区,每个内存分区包括完全独立的内存控制器和一个或两个DRAM设备,这些DRAM设备由该分区完全和独占拥有。为了实现最佳的负载平衡,并因此接近n个分区的理论性能,地址在所有内存分区之间均匀地精细交错,分区交错步长通常是几百字节的块,内存分区的数量旨在平衡处理器和其他内存请求者的数量。
19.9.4.2 缓存
GPU工作负载通常具有数百兆字节量级的非常大的工作集,以生成单个图形帧。与CPU不同,在足够大的芯片上构建缓存以容纳接近图形应用程序全部工作集的内容是不现实的。尽管CPU可以假设非常高的缓存命中率(99.9%或更高),但GPU的命中率接近90%,因此必须应对运行中的许多未命中。虽然CPU可以合理地设计为在等待罕见的缓存未命中时停滞,但GPU需要处理混合的未命中和命中。我们称之为流缓存架构(streaming cache architecture)。
GPU缓存必须为其客户端提供非常高的带宽。考虑纹理缓存的情况,典型的纹理单元可以为每个时钟周期四个像素中的每一个执行两个双线性插值,并且GPU可以具有许多这样的纹理单元,所有这些纹理单元都独立地操作。每个双线性插值需要四个单独的纹素,每个纹素可能是64位值,四个16位组件是典型的,因此总带宽为2×4×4×64=2048位/时钟。每个单独的64位纹素都是独立寻址的,因此缓存需要每个时钟处理32个唯一的地址。这自然有利于SRAM阵列的多组和/或多端口布置。
19.9.4.3 MMU
现代GPU能够将虚拟地址转换为物理地址。在GeForce 8800上,所有处理单元都在40位虚拟地址空间中生成内存地址。对于计算,加载和存储线程指令使用32位字节地址,通过添加40位偏移量将其扩展为40位虚拟地址。内存管理单元执行虚拟到物理地址转换;硬件从本地内存中读取页表,以代表分布在处理器和渲染引擎之间的翻译后备缓冲区的层次结构来响应未命中。除了物理页面位之外,GPU页面表条目还指定了每个页面的压缩算法,页面大小从4到128 KB不等。
CUDA公开了不同的内存空间,以允许程序员以最佳性能的方式存储数据值。下图是CPU和GPU内存请求路线:
GTT/GART作为CPU-GPU共享缓冲区用于通信:
后面小节的讨论以NVIDIA Tesla架构GPU为基准。
19.9.4.4 全局内存
全局内存存储在外部DRAM中,不是任何一个物理流多处理器(SM)的局部,因为它用于不同网格中不同CTA(线程块)之间的通信。事实上,引用全局内存中某个位置的许多CTA可能不会同时在GPU中执行,通过设计,在CUDA中,程序员不知道CTA执行的相对顺序。由于地址空间均匀分布在所有内存分区之间,因此必须有从任何流式多处理器到任何DRAM分区的读/写路径。
不同线程(和不同处理器)对全局内存的访问不能保证具有顺序一致性。线程程序看到一个宽松的(relaxed)内存排序模型,在线程中,内存对同一地址的读写顺序被保留,但对不同地址的访问顺序可能不会被保留。不同线程请求的内存读取和写入是无序的,在CTA中,屏障同步指令bar.sync可用于在CTA的线程之间获得严格的内存排序。membar线程指令提供了一个内存屏障/栅栏操作,该操作提交先前的内存访问,并在继续之前使其他线程可见。线程还可以使用原子内存操作来协调它们共享的内存上的工作。
19.9.4.5 共享内存
逐CTA共享内存仅对属于该CTA的线程可见,并且共享内存仅从创建CTA到终止CTA期间占用存储空间,因此共享内存可以驻留在芯片上。这种方法有以下好处:
- 共享内存流量不需要与全局内存引用所需的有限片外带宽竞争。
- 在芯片上构建非常高带宽的内存结构以支持每个流式多处理器的读/写需求是可行的。事实上,共享内存与流式多处理器紧密耦合。
每个流式多处理器包含八个物理线程处理器。在一个共享内存时钟周期内,每个线程处理器可以处理两个线程的指令,因此每个时钟必须处理16个线程的共享内存请求。因为每个线程都可以生成自己的地址,并且地址通常是唯一的,所以共享内存是使用16个可独立寻址的SRAM bank构建的。对于常见的访问模式,16个bank足以保持吞吐量,但也可能存在极端情况,例如所有16个线程可能恰好访问一个SRAM组上的不同地址。必须能够将请求从任何线程通道路由到任何SRAM组,因此需要16*16的互连网络。
19.9.4.6 局部内存
逐线程局部内存是仅对单个线程可见的专用内存。局部内存在架构上大于线程的寄存器文件,程序可以将地址计算到局部内存中。为了支持局部内存的大量分配(回想一下,总分配是每线程分配乘以活动线程数),局部内存分配在外部DRAM中。虽然全局和逐线程局部内存驻留在芯片外,但它们非常适合缓存在芯片上。
19.9.4.7 常量内存
常量内存对SM上运行的程序是只读的(可以通过命令写入GPU),存储在外部DRAM中,并缓存在SM中。因为通常SIMT warp中的大多数或所有线程都是从常量内存中的同一地址读取的,所以每个时钟的单个地址查找就足够了。常量缓存被设计为向每个warp中的线程广播标量值。
19.9.4.8 纹理内存
纹理内存保存大型只读数据数组,用于计算的纹理与用于3D图形的纹理具有相同的属性和功能。虽然纹理通常是二维图像(像素值的2D阵列),但也可以使用1D(线性)和3D(体积)纹理。
计算程序使用tex指令引用纹理,操作数包括用于命名纹理的标识符,以及基于纹理维度的一个、两个或三个坐标。浮点坐标包括指定样本位置的分数部分,通常位于纹素位置之间。在将结果返回到程序之前,非整数坐标调用四个最接近值(对于2D纹理)的双线性加权插值。
纹理提取缓存在流缓存层次结构中,该层次结构旨在优化数千个并发线程的纹理提取吞吐量。一些程序使用纹理提取作为缓存全局内存的方法。
19.9.4.9 表面(Surface)
表面是一维、二维或三维像素值阵列及其相关格式的通用术语,定义了多种格式,例如4个8位RGBA整数分量或4个16位浮点分量。程序内核不需要知道表面类型,tex指令根据表面格式将其结果值重新转换为浮点。
19.9.4.10 加载/存储访问
带有整数字节寻址的加载/存储指令允许用C和C++等传统语言编写和编译程序,CUDA程序使用加载/存储指令来访问内存。
为了提高内存带宽并减少开销,当地址位于同一块中并满足对齐标准时,局部和全局加载/存储指令将来自同一warp的单个并行线程请求合并为单个内存块请求。将单个小内存请求合并为大数据块请求可以显著提高单独请求的性能,大的线程数,加上支持许多未完成的负载请求,有助于覆盖外部DRAM中实现的局部和全局内存的负载使用延迟。
19.9.4.11 ROP
NVIDIA Tesla架构GPU包括可扩展流处理器阵列(SPA)和可扩展内存系统,可扩展流处理阵列执行GPU的所有可编程计算,可扩展内存系统包括外部DRAM控制和固定功能光栅操作处理器(Raster Operation Processor,ROP),可直接在内存上执行颜色和深度帧缓冲操作。每个ROP单元与特定的内存分区配对,ROP分区通过互连网络被SM填充数据。每个ROP负责深度和模板测试和更新,以及颜色混合。ROP和内存控制器协作实现无损颜色和深度压缩(高达8:1),以减少外部带宽需求,ROP单元还对内存执行原子操作。
19.9.5 浮点运算
如今的GPU使用IEEE 754兼容的单精度32位浮点运算在可编程处理器内核中执行大多数算术运算,早期GPU的定点算法是由16位、24位和32位浮点,然后是IEEE 754兼容的32位浮点继承的。GPU中的一些固定功能逻辑,如纹理过滤硬件,继续使用专有的数字格式,部分GPU还提供IEEE 754兼容的双精度64位浮点指令。
19.9.5.1 支持的格式
IEEE 754浮点算术标准规定了基本格式和存储格式。GPU使用两种基本的计算格式,32位和64位二进制浮点,通常称为单精度和双精度,该标准还指定了16位二进制存储浮点格式,半精度。GPU和Cg着色语言采用窄16位半数据格式,以实现高效的数据存储和移动,同时保持高动态范围,GPU在纹理过滤单元和光栅操作单元内以半精度执行许多纹理过滤和像素混合计算。Industrial Light and Magic[2003]开发的OpenEXR高动态范围图像文件格式在计算机成像和运动图像应用中使用相同的半格式颜色分量值。
半精度(half precision):一种16位二进制浮点格式,具有1个符号位、5位指数、10位小数和一个隐含整数位。
19.9.5.2 基本算术
GPU可编程内核中常见的单精度浮点运算包括加法、乘法、乘法、最小值、最大值、比较、设置判断以及整数和浮点数之间的转换,浮点指令通常为求反和绝对值提供源操作数修饰符。
乘加(multiply-add,MAD):一种执行复合运算的单浮点指令——乘法后相加。
今天大多数GPU的浮点加法和乘法运算都与IEEE 754标准兼容,适用于单精度FP数,包括非数字(NaN)和无穷大值。FP加法和乘法运算使用IEEE舍入到最接近,甚至作为默认舍入模式。为了提高浮点指令吞吐量,GPU通常使用复合乘加指令(mad),mad运算执行带截断的FP乘法,然后执行带舍入到最接近偶数的FP加法。它在一个发出周期内提供两个浮点运算,而不需要指令调度器调度两个单独的指令,但计算没有融合,并在加法之前截断乘积,使得它不同于后面讨论的融合乘加(fused multiply-add)指令。GPU通常会将非规范化的源操作数刷新为符号保留零,并在舍入后将目标输出指数范围下溢的结果刷新为符号保持零。
19.9.5.3 特殊算术
GPU提供硬件来加速特殊函数计算、属性插值和纹理过滤,特殊函数指令包括余弦、正弦、二元指数、二元对数、倒数和平方根倒数。属性插值指令提供了从平面方程求值导出的像素属性的有效生成,前面介绍的特殊函数单元(SFU)计算特殊函数并插值平面属性。
特殊函数单元(special function unit,SFU):计算特殊函数和插值平面属性的硬件单元。
有几种方法可用于执行硬件中的特殊功能。已经表明,基于增强的Minimax逼近的二次插值是一种非常有效的硬件函数逼近方法,包括倒数、倒数平方根、log2xlog2x、2x2x、sin和cos。
我们可以总结SFU二次插值的方法。对于具有n位有效位的二进制输入操作数X,有效位分为两部分:XuXu是包含m位的上部,XlXl是包含n-m位的下部。较高的m位XuXu用于查询一组三个查找表,以返回三个有限域系数C0、C1和C2。要近似的每个函数都需要一组唯一的表,这些系数用于近似Xu≤X<Xu+2−mXu≤X<Xu+2−m范围内的给定函数f(X),通过计算表达式:
f(X)=C0+C1X1+C2X21f(X)=C0+C1X1+C2X12
每个函数计算的精度范围为22到24个有效位,示例功能统计如下图所示。
IEEE 754标准规定了除法和平方根的精确舍入要求,但对于许多GPU应用程序,不需要严格遵守,相反,更高的计算吞吐量比最后一位精度更重要。对于SFU特殊函数,CUDA数学库提供了全精度函数和具有SFU指令精度的快速函数。
GPU中的另一种特殊算术运算是属性插值,通常为构成要渲染的场景的图元的顶点指定关键点属性,例如颜色、深度和纹理坐标。必须根据需要在(x,y)屏幕空间内插入这些属性,以确定每个像素位置的属性值,(x,y)平面中给定属性U的值可以使用以下形式的平面方程表示:
U(x,y)=Aux+BuY+CuU(x,y)=Aux+BuY+Cu
其中A、B和C是与每个属性U关联的插值参数,插值参数A、B、C都表示为单精度浮点数。
考虑到像素着色器处理器中同时需要函数求值器和属性插值器,可以设计一个执行这两个函数以提高效率的SFU。两个函数都使用乘积和运算来插值结果,两个函数中要求和的项数非常相似。
纹理映射和过滤是GPU中另一组关键的专用浮点算术运算。用于纹理映射的操作包括:
1.接收当前屏幕像素(x,y)的纹理地址(s,t),其中s和t是单精度浮点数。
2.计算细节级别以识别正确的纹理MIPmap级别。
3.计算三线性插值分数。
4.缩放所选MIP映射级别的纹理地址(s,t)。
5.访问存储器并检索期望的纹素(纹理元素)。
6.对纹素执行过滤操作。
MIP-map:包含不同分辨率的预计算图像,用于提高渲染速度和减少伪影。
纹理映射对于全速操作需要大量的浮点计算,其中大部分是以16位半精度完成的,例如除了传统的IEEE单精度浮点指令外,GeForce 8800 Ultra还为纹理映射指令提供了约500GFLOPS的专有格式浮点计算。
浮点加法和乘法运算硬件是完全管线化的,延迟被优化以平衡延迟和面积。虽然采用管线,但特殊函数的吞吐量小于浮点加法和乘法运算,特殊函数的四分之一速度吞吐量是现代GPU的典型性能,一个SFU由四个SP核共享。相比之下,CPU对于类似的功能(如除法和平方根)通常具有明显更低的吞吐量,尽管结果更准确。属性插值硬件通常完全管线化,以启用全速像素着色器。
19.9.5.4 双精度
Tesla T10P等GPU也支持硬件中的IEEE 754 64位双精度操作。双精度标准浮点算术运算包括加法、乘法以及不同浮点和整数格式之间的转换。2008年IEEE 754浮点标准包括融合乘加(fused-multiply-add,FMA)操作的规范,FMA操作执行浮点乘法,然后执行加法,并进行一次舍入,融合的乘法和加法运算在中间计算中保持了完全的精度。这种行为可以实现更精确的浮点计算,包括积的累加,包括点积、矩阵乘法和多项式求值。FMA指令还实现了精确舍入除法和平方根的高效软件实现,无需硬件除法或平方根单元。
双精度硬件FMA单元实现64位加法、乘法、转换和FMA运算本身,双精度FMA单元的体系结构可在输入和输出上实现全速非标准化数支持。下图显示了FMA单元的结构。
双精度融合乘加(FMA)单元,硬件实现双精度浮点A×B+C。
如上图所示,A和B的有效位相乘形成106位乘积,结果保留进位形式,并行地,53位加数C有条件地反转并与106位乘积对齐,106位乘积的和和进位结果通过161位宽进位保存加法器(CSA)与对齐的加数相加。然后,进位保存输出在进位传播加法器中相加,以产生一个非冗余二进制补码形式的非舍入结果。结果被有条件地重新计算,以便以符号大小形式返回结果,补码结果被归一化,然后被舍入以符合目标格式。
19.9.6 可编程GPU
编程多处理器GPU与编程其他多处理器(如多核CPU)有本质上的不同。GPU比CPU提供了两到三个数量级的线程和数据并行性,可扩展到数百个处理器内核和数万个并发线程。GPU继续提高其并行性,大约每12到18个月将其翻倍,这是摩尔定律提高集成电路密度和提高架构效率的结果。为了跨越不同细分市场的广泛价格和性能范围,不同的GPU产品实现了不同数量的处理器和线程。然而,用户希望游戏、图形、图像和计算应用程序能够在任何GPU上运行,无论它执行多少并行线程或拥有多少并行处理器内核,而且他们希望更昂贵的GPU(具有更多线程和内核)能够更快地运行应用程序。因此,GPU编程模型和应用程序被设计为透明地扩展到广泛的并行度。
GPU中大量并行线程和内核背后的驱动力是实时图形性能——需要以每秒至少60帧的交互式帧速率以高分辨率渲染复杂的3D场景。相应地,图形着色语言(如Cg、HLSL、GLSL)的可扩展编程模型被设计为通过许多独立的并行线程利用大程度的并行性,并可扩展到任意数量的处理器核。CUDA可扩展并行编程模型类似地使通用并行计算应用程序能够利用大量并行线程,并可扩展到任意数量的并行处理器内核,对应用程序透明。
在这些可扩展编程模型中,程序员为单个线程编写代码,GPU并行运行无数线程实例,所以程序可以在广泛的硬件并行性上透明地扩展。这种简单的范例源自图形API和描述如何对一个顶点或一个像素进行着色的着色语言,自20世纪90年代末以来,随着GPU快速提高其并行性和性能,一直是一个有效的范例。
本节简要介绍使用图形API和编程语言为实时图形应用程序编程GPU,然后介绍使用C语言和CUDA编程模型为可视化计算和通用并行计算应用程序编程GPU。
API在GPU和处理器的快速、成功开发中发挥了重要作用。有两个主要的标准图形API:OpenGL和Direct3D。OpenGL是一种开放标准,最初由Silicon Graphics Incorporated提出并定义,OpenGL标准的持续开发和扩展由行业协会Khronos管理。Direct3D是一种事实上的标准,由微软和合作伙伴定义并向前发展。OpenGL和Direct3D的结构相似,并随着GPU硬件的进步不断快速发展,它们定义了映射到GPU硬件和处理器上的逻辑图形处理管线,以及可编程管道阶段的编程模型和语言。
下图说明了Direct3D 10逻辑图形管线,OpenGL具有类似的图形管线结构。API和逻辑管线为可编程着色器阶段提供了流数据流基础设施和管道,如蓝色所示。3D应用程序向GPU发送分组为几何图元点、线、三角形和多边形的顶点序列,输入装配程序收集顶点和基元。顶点着色器程序执行逐顶点处理,包括将顶点3D位置转换为屏幕位置并照亮顶点以确定其颜色,几何着色器程序执行逐图元处理,并可以添加或删除图元,设置和光栅化单元生成由几何图元覆盖的像素片段(片段是对像素的潜在贡献)。
像素着色器程序执行每片段处理,包括插值每片段参数、纹理和着色。像素着色器使用插值浮点坐标,广泛使用采样和过滤查找到大型1D、2D或3D阵列(称为纹理)中。着色器使用贴图、函数、贴花、图像和数据的纹理访问。光栅操作处理(或输出合并)阶段执行Z缓冲深度测试和模板测试,这可以丢弃隐藏的像素片段或用片段的深度替换像素的深度,并执行颜色混合操作,该操作将片段颜色与像素颜色相结合,并用混合的颜色写入像素。
图形API和图形管道为处理每个顶点、图元和像素片段的着色器程序提供输入、输出、内存对象和基础结构。
19.9.6.1 编程并行计算应用程序
图形处理模型实际上是多线程、多编程和SIMD执行的组合,NVIDIA称其型号为SIMT(单指令、多线程)。让我们看看NVIDIA的SIMT执行模型。
程序员首先用CUDA编程语言编写代码。CUDA代表计算统一设备架构,是C/C++的自定义扩展,由NVIDIA的nvcc编译器编译,以在CPU的ISA(用于CPU)和PTX指令集(用于GPU)中生成代码。CUDA程序包含一组在GPU上运行的内核和一组在主机CPU上运行的函数。主机CPU上的功能将数据传输到GPU和从GPU传输数据,初始化变量,并协调GPU上内核的执行,内核被定义为在GPU上并行执行的函数。图形硬件为每个CUDA内核创建多个副本,每个副本在单独的线程上执行。
GPU将每个这样的线程映射到SP核心。可以为单个CUDA内核无缝创建和执行数百个线程。有些人可能会认为,如果多个副本的代码相同,那么运行多个副本有什么意义。答案是代码并不完全相同,代码隐式地将线程的id作为输入,例如,如果我们为每个CUDA内核生成100个线程,那么每个线程在集合[0...99]中都有一个唯一的id,CUDA内核中的代码根据线程的id执行适当的处理。许多单独应用程序的线程可能同时运行,每个SM的MT发布逻辑调度线程并协调其执行。这种架构中的SM可以处理多达768个线程。
如果我们并行运行多个应用程序,那么GPU作为一个整体将需要调度数千个线程,调度开销过高。因此,为了简化调度任务,GeForce 8800 GPU将一组32个线程组合成一个warp。每个SM可以管理24个warp,warp是线程的原子单位,warp中的所有线程都被调度,或者warp中没有线程被调度。此外,warp中的所有线程都属于同一内核,并且从完全相同的地址开始。然而,在它们启动之后,可以有不同的程序计数器。
每个SM将warp的线程映射到SP核心,它按指令执行warp指令,类似于经典的SIMD执行,我们在多个数据流上执行一条指令,然后转到下一条指令。SM为warp中的每个线程执行一条指令,在所有线程完成该指令后,它执行下一条指令。如果内核有一个依赖于数据或线程的分支,那么SM只为那些在正确的分支路径中有指令的线程执行指令。GeForce GPU使用预测指令,对于错误路径上的指令,判断条件为false,因此这些指令被nop指令动态替换。一旦分支路径(已执行和未执行)重新合并,warp中的所有线程将再次激活。与SIMD模型的主要区别在于,在SIMD处理器中,同一线程处理同一指令中的多个数据流。然而,在这种情况下,同一条指令在多个线程中执行,每条指令对不同的数据流进行操作。在warp中执行指令后,MT执行单元可能会调度相同的warp、来自相同应用程序的另一个warp或来自另一个应用程序的warp。GPU本质上实现了warp级别的细粒度多线程,下图显示了一个示例。
Warp的调度。
对于32线程的执行,SM通常使用4个周期。在第一个周期中,它向8个SP核心中的每一个发出8个线程。在第二个周期中,它向SFU再发出8个线程。由于两个SFU各有4个功能单元,因此它们可以并行处理8个指令,而不会产生任何结构冲突。在第三个周期中,又向SP核心发送了8个线程,最后在第四个周期中向两个SFU核心发送8个线程。这种在使用SFU和SP核心之间切换的策略确保了两个单元都保持忙碌。由于warp是一个原子单元,它不能在SM之间拆分,并且warp的每条指令必须在所有活动线程上执行完毕,然后才能执行warp中的下一条指令。我们可以在概念上将warp的概念等同于32通道宽的SIMD机器,同一应用程序中的多个warp可以独立执行。为了在warp之间进行同步,我们需要使用全局内存,或者现代GPU中可用的复杂同步原语。
CUDA、Brook和CAL是GPU的编程接口,专注于数据并行计算而不是图形。CAL(计算抽象层)是AMD GPU的低级汇编语言接口,Brook是Buck等人的一种适用于GPU的流式语言,由NVIDIA开发的CUDA是C和C++语言的扩展,用于多核GPU和多核CPU的可扩展并行编程。
凭借新模型,GPU在数据并行和吞吐量计算方面表现出色,可执行高性能计算应用程序和图形应用程序。
为了有效地将大型计算问题映射到高度并行的处理架构,程序员或编译器将问题分解为许多可以并行解决的小问题。例如,程序员将一个大的结果数据数组划分为块,并将每个块进一步划分为元素,从而可以并行地独立计算结果块,并且并行地计算每个块内的元素。下图显示了将结果数据数组分解为3×2块网格,其中每个块进一步分解为5×3元素数组。两级并行分解自然映射到GPU架构:并行多处理器计算结果块,并行线程计算结果元素。
将结果数据分解为要并行计算的元素块网格。
程序员编写一个程序来计算一系列结果数据网格,将每个结果网格划分为粗粒度的结果块,这些块可以独立并行计算。程序使用细粒度并行线程数组计算每个结果块,在线程之间划分工作,以便每个线程计算一个或多个结果元素。
19.9.6.2 CUDA编程
CUDA可扩展并行编程模型扩展了C和C++语言,以在高度并行的多处理器(特别是GPU)上为通用应用程序开发大量并行性,早期经验表明,许多复杂的程序可以用一些容易理解的抽象来表达。自2007年NVIDIA发布CUDA以来,开发人员迅速开发了可扩展的并行程序,用于广泛的应用,包括地震数据处理、计算化学、线性代数、稀疏矩阵求解器、排序、搜索、物理模型和可视化计算,这些应用程序可以透明地扩展到数百个处理器内核和数千个并发线程。具有Tesla统一图形和计算架构的NVIDIA GPU运行CUDA C程序,并广泛用于笔记本电脑、PC、工作站和服务器。CUDA模型也适用于其他共享内存并行处理架构,包括多核CPU。
CUDA提供了三个关键抽象——线程组的层次结构、共享内存和屏障同步,为层次结构中的一个线程提供了与传统C代码的清晰并行结构。多级线程、内存和同步提供细粒度数据并行和线程并行,嵌套在粗粒度数据并行和任务并行中,抽象指导程序员将问题划分为可以独立并行解决的粗略子问题,然后划分为可以并行解决的更精细的部分。编程模型可以透明地扩展到大量处理器内核:编译后的CUDA程序可以在任意数量的处理器上执行,只有运行时系统才需要知道物理处理器的数量。
CUDA是C和C++编程语言的最小扩展,程序员编写一个调用并行内核的串行程序,可以是简单的函数,也可以是完整的程序。内核跨一组并行线程并行执行,程序员将这些线程组织成线程块的层次结构和线程块的网格。线程块是一组并发线程,它们可以通过屏障同步和共享访问块专用的内存空间来相互协作。网格是一组线程块,每个线程块可以独立执行,因此可以并行执行。
内核(kernel):一个线程的程序或函数,设计为可由多个线程执行。
线程块(thread block):一组并发线程,它们执行相同的线程程序,并可以协作计算结果。
网格(grid):执行同一内核程序的一组线程块。
线程、块和网格之间的关系。
CUDA术语与GPU硬件组件等效映射如下表:
CUDA术语 | 定义 | 等效的GPU硬件组件 |
---|---|---|
内核(Kernel) | 在GPU上运行的函数形式的并行代码 | 不适用 |
线程(Thread) | GPU上内核的实例 | GPU/CUDA处理器核心 |
块(Block) | 分配给特定SM的一组线程 | CUDA多处理器(SM) |
网格(Grid) | GPU | GPU |
CUDA程序自然映射到GPU的结构。我们首先在CUDA中编写一个内核,该内核根据运行时分配给它的线程id执行一组操作,内核的动态实例是线程(类似于CPU上下文中的线程)。我们将一组线程分组为一个块(block)或CTA(协作线程数组),块或CTA对应于warp,一个块中可以有1-512个线程,每个SM在任何时间点最多可以缓冲8个块的状态。块中的每个线程都有一个唯一的线程id,类似地,块被分组在一个网格中,网格包含应用程序的所有线程,不同的块(或warp)可以彼此独立地执行,除非我们明确实施某种形式的同步。在我们的简单示例中,将块视为线程的线性数组,将网格视为块的线性数组。此外,可以将块定义为线程的2D或3D数组,或者将网格定义为块的2D或三维数组。
现在来看一个小型CUDA程序,它添加了两个n元素数组,让我们部分考虑CUDA调度。在下面的代码片段中,初始化了三个数组a、b和c,希望添加a和b元素,并将结果保存在c中。
#define N 1024
void main()
{
// 声明数组
int a[N], b[N], c[N];
// 在GPU中声明相应的数组
int size = N * sizeof(int);
int *gpu_a, *gpu_b, *gpu_c;
// 为GPU中的数组分配空间
cudaMalloc((void**) &gpu_a, size);
cudaMalloc((void**) &gpu_b, size);
cudaMalloc((void**) &gpu_c, size);
// 初始化数组
(...)
// 拷贝数组到GPU
cudaMemcpy (gpu_a, a, size, cudaMemcpyHostToDevice);
cudaMemcpy (gpu_b, b, size, cudaMemcpyHostToDevice);
}
在这个代码片段中,声明了三个数组(a、b和c),其中包含N个元素,随后定义了它们在gpu中的相应存储位置。然后,使用cudaMalloc调用在GPU中为它们分配空间。接下来,用值初始化数组a和b(代码未显示),然后使用CUDA函数cudaMemcpy将这些数组复制到gpu中的相应位置(gpu_a和gpu_b),它使用名为cudaMemcpyHostToDevice的标志,其中主机是CPU,设备是GPU。
下一个操作是在gpu中添加向量gpu_a和gpu_b。为此,我们需要编写一个vectorAdd函数来添加向量。此函数应包含三个参数,由两个输入向量和一个输出向量组成。下面展示调用此函数的代码。
vectorAdd <<< N/32, 32 >>> (gpu_a, gpu_b, gpu_c);
我们使用三个参数调用vectorAdd函数:gpu_a、gpu_b和gpu_c。表达式<<< N/32, 32 >>>向GPU表明,有N=32个块,每个块包含32个线程。假设GPU神奇地添加了两个数组,并将结果保存在其物理内存空间中的数组gpu_c中。主功能的最后一步是从GPU获取结果,并释放GPU中的空间,其代码如下。
/* Copy from the GPU to the CPU */
cudaMemcpy(c, gpu_c, size, cudaMemcpyDeviceToHost);
/* free space in the GPU */
cudaFree(gpu_a);
cudaFree(gpu_b);
cudaFree(gpu_c);
/* end of the main function */
现在,让我们定义需要在GPU上执行的vectorAdd函数。
/* The GPU kernel */
__global__ void vectorAdd( int *gpu a, int *gpu b, int *gpu c)
{
/* compute the index */
int idx = threadIdx.x + blockIdx.x * blockDim.x;
/* perform the addition */
gpu_c[idx] = gpu_a[idx] + gpu_b[idx];
}
上述代码中,访问CUDA运行时填充的一些内置变量,通常情况网格和块有三个轴(x, y, z)。因为我们在这个例子中假设块和网格中只有一个轴,所以我们只使用x轴。变量blockDim.x等于块中的线程数。如果我们考虑二维网格,那么块的尺寸将是blockDim.x*blockDim.y
,blockIdx.x是块的索引,threadIdx.x是块中线程的索引,因此表达式threadIdx.x+blockIdx.x * blockDim.x
表示线程的索引。注意此示例中,数组的每个元素与一个线程相关联。由于创建、初始化和切换线程的开销很小,因此我们可以在GPU的情况下采用这种方法,如果CPU在创建和管理线程时开销很大,那么这种方法是不可行的。一旦计算了线程的索引,就执行加法运算。
GPU创建此内核的N个副本,并将其分发给N个线程。每个内核计算不同的索引,然后执行加法运算。然而,使用CUDA扩展到C/C++,可以编写极其复杂的程序,其中包含同步语句和条件分支语句。
下面再举个并行编程的一个简单的例子,假设我们得到了n个浮点数的两个向量x和y,并且希望计算某个标量值a的y=ax+y的结果,正是BLAS线性代数库定义的所谓SAXPY内核。下面显示了使用CUDA在串行处理器和并行处理器上执行此计算的C代码。
// 用串行循环计算y=ax+y
void saxpy_serial( int n, float alpha, float * x, float )
{
for( int i=0; i<n; ++i)
y[i] = alpha * x[i] + y[i];
}
// 调用串行SAXPY内核
saxpy_serial(n, 2.0, x, y);
// 用CUDA并行计算y=ax+y
__global__
void saxpy_parallel( int n, float alpha, float *x, float *y)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
if(i < n)
y[i] = alpha * x[i] + y[i];
}
// Invoke parallel SAXPY kernel (256 threads per block)
int nblocks = (n + 255) / 256;
saxpy_parallel<<< nblocks, 256>>> ( n, 2.0, x, y);
__global__
声明说明符表示过程是内核入口点,CUDA程序使用扩展函数调用语法启动并行内核:
kernel<<<dimGrid, dimBlock>>>(… parameter list …);
其中,dimGrid和dimBlock是dim3类型的三个元素向量,分别指定网格在块中的尺寸和线程中的块的尺寸。未指定的尺寸默认为1。
上述代码启动了一个由n个线程组成的网格,为向量的每个元素分配一个线程,并在每个块中放置256个线程。每个单独的线程根据其线程和块ID计算元素索引,然后对相应的向量元素执行所需的计算。比较这段代码的串行和并行版本,会发现它们非常相似,是一种相当常见的模式。串行代码由一个循环组成,其中每个迭代都独立于所有其他迭代。这样的循环可以机械地转换为并行内核:每个循环迭代都成为一个独立的线程。通过为每个输出元素分配一个线程,避免了在将结果写入内存时线程之间的任何同步。
CUDA内核的文本只是一个顺序线程的C函数,因此通常很容易编写,并且比为向量运算编写并行代码更简单。通过在启动内核时指定网格及其线程块的维度,可以明确地确定并行性。
并行执行和线程管理是自动的,所有线程的创建、调度和终止都由底层系统为程序员处理。事实上,Tesla架构GPU直接在硬件中执行所有线程管理。块的线程同时执行,并且可以通过调用__syncthreads()
内在函数在同步屏障处同步,以此保证在块中的所有线程都到达屏障之前,块中的任何线程都不能继续。在通过屏障之后,这些线程还可以确保在屏障之前看到块中的线程对内存执行的所有写入。因此,块中的线程可以通过在同步屏障处写入和读取每个块共享内存来彼此通信。
同步屏障(synchronization barrier):线程在同步屏障处等待,直到线程块中的所有线程到达该屏障。
由于块中的线程可以共享内存并通过屏障进行同步,因此它们将一起驻留在同一物理处理器或多处理器上,但线程块的数量可能大大超过处理器的数量。CUDA线程编程模型将处理器虚拟化,并使程序员能够灵活地以最方便的粒度进行并行化。虚拟化为线程和线程块允许直观的问题分解,因为块的数量可以由正在处理的数据的大小决定,而不是由系统中的处理器数量决定,它还允许相同的CUDA程序扩展到不同数量的处理器内核。
为了管理这种处理元素虚拟化并提供可扩展性,CUDA要求线程块能够独立执行,必须能够以任何顺序并行或串行执行块。不同的块没有直接通信的方式,尽管它们可以通过例如原子递增队列指针,使用对所有线程可见的全局内存上的原子内存操作来协调它们的活动。这种独立性要求允许跨任意数量的内核以任意顺序调度线程块,从而使CUDA模型可跨任意数量内核以及多种并行架构进行扩展,也有助于避免死锁的可能性。应用程序可以独立或独立地执行多个网格,给定足够的硬件资源,独立网格可以同时执行。从属网格按顺序执行,其间有一个隐式内核间屏障,从而保证第一个网格的所有块在第二个从属网格的任何块开始之前完成。
原子内存操作(atomic memory operation):一种内存读取、修改、写入操作序列,在没有任何干预访问的情况下完成。
线程在执行过程中可以从多个内存空间访问数据,每个线程都有一个专用局部内存,CUDA对不适合线程寄存器的线程专用变量以及堆栈帧和寄存器溢出使用本地内存。每个线程块都有一个共享内存,该内存对该块的所有线程都可见,并且与该块具有相同的生存期。最后,所有线程都可以访问相同的全局内存,程序使用__shared__
和__device__
类型限定符在共享和全局内存中声明变量。在Tesla架构的GPU上,这些内存空间对应于物理上独立的内存:每个块共享内存是一个低延迟的片上RAM,而全局内存驻留在图形板上的快速DRAM中。
局部内存(local memory):线程专用的逐线程局部内存。
共享内存(shared memory):块的所有线程共享的逐块内存。
全局内存(global memory):所有线程共享的逐应用程序内存。
共享内存应该是每个处理器附近的低延迟内存,很像L1缓存,因此它可以在线程块的线程之间提供高性能通信和数据共享。由于它的生存期与其对应的线程块相同,内核代码通常会初始化共享变量中的数据,使用共享变量进行计算,并将共享内存结果复制到全局内存。顺序相关网格的线程块通过全局内存进行通信,使用它来读取输入和写入结果。
下图显示了线程、线程块和线程块网格的嵌套级别图,还显示了相应的内存共享级别:逐线程、逐线程块和逐应用程序数据共享的局部、共享和全局内存。
嵌套粒度级别线程、线程块和网格具有相应的局部、共享和全局内存共享级别。逐线程局部内存是线程专用的,逐块共享内存由块的所有线程共享,逐应用程序的全局内存由所有线程共享。
程序通过调用CUDA运行时(如cudaMalloc()和cudaFree())来管理内核可见的全局内存空间。内核可以在物理上独立的设备上执行,就像在GPU上运行内核一样,所以应用程序必须使用cudaMemcpy()在分配的空间和主机系统内存之间复制数据。
CUDA编程模型在风格上类似于熟悉的单程序多数据(SPMD)模型,它显式地表示并行性,每个内核在固定数量的线程上执行。然而,CUDA比SPMD的大多数实现更灵活,因为每个内核调用都会动态地创建一个新的网格,其中包含正确数量的线程块和应用程序步骤的线程。程序员可以为每个内核使用方便的并行度,而不必设计计算的所有阶段来使用相同数量的线程。下图显示了类似SPMD的CUDA代码序列的示例。它首先在3×2块的2D网格上实例化内核F,其中每个2D线程块由5×3个线程组成。然后,它在四个一维线程块的一维网格上实例化内核G,每个一维线程块有六个线程。因为kernelG依赖于kernelF的结果,所以它们被内核间同步屏障分隔开。
单程序多数据(single-program multiple data,SPMD):一种并行编程模型,其中所有线程执行同一程序。SPMD线程通常与屏障同步协调。
在2D线程块的2D网格上实例化的内核F序列,是一个内核间同步屏障,之后是1D线程块的1D网格上的内核G。
线程块的并发线程表示细粒度数据并行和线程并行,网格的独立线程块表示粗粒度数据并行性,独立网格表示粗粒度任务并行性。内核只是层次结构中一个线程的C代码。
请注意,我们将GPU内核与CPU执行的代码合并为一个程序,NVIDIA的编译器将单个文件拆分为两个二进制文件,一个二进制在CPU上运行并使用CPU的指令集,另一个二进制运行在GPU上并使用PTX指令集。这是一个典型的MPMD执行方式的例子,在不同的指令集和多个数据流中有不同的程序。因此,可以将GPU的并行编程模型视为SIMD、MPMD和warp级别的细粒度多线程的组合(下图)。
为了提高效率并简化其实现,CUDA编程模型有一些限制。线程和线程块只能通过调用并行内核而不是从并行内核中创建,再加上线程块所需的独立性,使得使用简单的调度器执行CUDA程序成为可能,该调度器引入了最小的运行时开销。事实上,Tesla GPU架构实现了线程和线程块的硬件管理和调度。
任务并行性可以在线程块级别表达,但很难在线程块中表达,因为线程同步障碍在块的所有线程上运行。为了使CUDA程序能够在任意数量的处理器上运行,同一内核网格内的线程块之间的依赖关系是不允许的。由于CUDA要求线程块是独立的并且允许以任何顺序执行块,组合由多个块生成的结果通常必须通过在线程块的新网格上启动第二个内核来完成(尽管线程块可以通过例如原子递增队列指针来使用对所有线程可见的全局内存上的原子内存操作来协调其活动)。
CUDA内核中当前不允许递归函数调用,递归在大规模并行内核中不具备吸引力,因为为数以万计的活动线程提供堆栈空间需要大量内存。通常使用递归(如快速排序)表示的串行算法通常最好使用嵌套数据并行而不是显式递归来实现。
为了支持将CPU和GPU结合在一起的异构系统架构,CUDA程序必须在主机内存和设备内存之间复制数据和结果。通过使用DMA块传输引擎和快速互连,CPU与GPU交互和数据传输的开销最小化,大到需要GPU性能提升的计算密集型问题比小问题更好地分摊开销。
图形和计算的并行编程模型使得GPU架构不同于CPU架构,驱动GPU处理器架构的GPU程序的关键方面是:
- 广泛使用细粒度数据并行性:着色器程序描述如何处理单个像素或顶点,CUDA程序描述如何计算单个结果。
- 高线程编程模型:着色器线程程序处理单个像素或顶点,CUDA线程程序可以生成单个结果。GPU必须以每秒60帧的速度每帧创建和执行数百万个这样的线程程序。
- 可扩展性:当提供额外的处理器时,程序必须自动提高性能,而无需重新编译。
- 密集型浮点(或整数)计算。
- 支持高吞吐量计算。
19.9.6.3 NVIDIA GPU内存结构
下图显示了NVIDIA GPU的内存结构,每个多线程SIMD处理器本地的片上内存称为局部内存。它由多线程SIMD处理器内的SIMD通道共享,但此内存不在多线程SIMC处理器之间共享,整个GPU和所有线程块共享的片外DRAM称为GPU内存。
GPU内存结构。GPU内存由矢量化循环共享,线程块中SIMD指令的所有线程共享局部内存。
GPU传统上使用较小的流式缓存,并依赖SIMD指令线程的广泛多线程处理来隐藏DRAM的长延迟,而不是依赖于大型缓存来包含应用程序的整个工作集,因为它们的工作集可能是数百M字节。因此,它们不适合多核微处理器的最后一级缓存。考虑到使用硬件多线程来隐藏DRAM延迟,系统处理器中用于缓存的芯片区域被用于计算资源和大量寄存器,以保存SIMD指令的许多线程的状态。
虽然隐藏内存延迟是基本原理,但请注意,最新的GPU和矢量处理器增加了缓存,例如最近的Fermi架构增加了缓存,但它们被认为是减少GPU内存需求的带宽过滤器,或者是多线程无法隐藏延迟的少数变量的加速器。用于堆栈帧、函数调用和寄存器溢出的本地内存与缓存非常匹配,因为调用函数时延迟很重要。缓存也可以节省能量,因为片上缓存访问比访问多个外部DRAM芯片消耗的能量少得多。
在高层次上,具有SIMD指令扩展的多核计算机确实与GPU有相似之处,下图总结了相似性和差异。两者都是MIMD,其处理器使用多个SIMD通道,尽管GPU有更多的处理器和更多的通道。两者都使用硬件多线程来提高处理器利用率,尽管GPU对更多线程具有硬件支持。两者都使用缓存,尽管GPU使用较小的流缓存,而多核计算机使用大型多级缓存,试图完全包含整个工作集。两者都使用64位地址空间,尽管GPU中的物理主内存要小得多。虽然GPU在页面级别支持内存保护,但它们还不支持按需分页。
特性 | 带SIMD的多核(CPU) | GPU |
---|---|---|
SIMD处理器 | 4到8 | 8到16 |
每个处理器的SIMD通道数 | 2到4 | 8到16 |
SIMD线程的多线程硬件支持 | 2到4 | 16到32 |
最大的缓存尺寸 | 8M | 0.75M |
内存地址尺寸 | 64-bit | 64-bit |
主内存尺寸 | 8G到256G | 4G到16G |
页面级别的内存保护 | 是 | 是 |
按需分页 | 是 | 否 |
缓存一致性 | 是 | 否 |
SIMD处理器也类似于矢量处理器。GPU中的多个SIMD处理器充当独立的MIMD核心,就像许多矢量计算机具有多个矢量处理器一样。这种观点认为Fermi GTX 580是一个16核机器,具有多线程硬件支持,每个核有16个通道。最大的区别是多线程,这是GPU的基础,也是大多数向量处理器所缺少的。
GPU和CPU在计算机体系结构谱系中不会追溯到共享祖先,没有缺失链接可以解释这两者。由于这种不同寻常的传统,GPU没有使用计算机架构社区中常见的术语,导致了对GPU是什么以及它们如何工作的困惑。为了帮助解决混淆,下图列出了本文部分使用的更具描述性的术语,与主流计算最接近的术语。
尽管GPU正朝着主流计算方向发展,但他们不能放弃继续在图形方面取得优异成绩的责任。因此,当架构师问,考虑到为做好图形而投入的硬件,我们如何补充它以提高更广泛应用程序的性能时,GPU的设计可能更有意义?
关于GPU的更多技术细节可参阅:深入GPU硬件架构及运行机制。
19.9.7 i7 960和Tesla GPU性能
Intel研究人员在2010年发表了一篇论文,将四核Intel core i7 960与上一代GPU NVIDIA Tesla GTX 280的多媒体SIMD扩展进行了比较。下表列出了这两种系统的特点。酷睿i7采用英特尔的45纳米半导体技术,而GPU采用台积电的65纳米技术。尽管由中立方或两个相关方进行比较可能更公平,但本节的目的不是确定一种产品比另一种产品快多少,而是试图了解这两种截然不同的架构风格的特征的相对价值。
特性 | Core i7-960 | GTX 280 | GTX 480 | 280/i7的比率 | 480/i7的比率 |
---|---|---|---|---|---|
处理元素(核或SM)的数量 | 4 | 30 | 15 | 7.5 | 3.8 |
时钟频率(GHz) | 3.2 | 1.3 | 1.4 | 0.41 | 0.44 |
模具(Die)尺寸 | 263 | 576 | 520 | 2.2 | 2.0 |
技术 | Intel 45 nm | TSMC 65 nm | TSMC 40 nm | 1.6 | 1.0 |
功率(芯片,非模块) | 130 | 130 | 167 | 1.0 | 1.3 |
晶体管 | 700 M | 1400 M | 3030 M | 2.0 | 4.4 |
内存带宽(G/sec) | 32 | 141 | 177 | 4.4 | 5.5 |
单精度SIMD宽 | 4 | 8 | 32 | 2.0 | 8.0 |
双精度SIMD宽 | 2 | 1 | 16 | 0.5 | 8.0 |
峰值单精度标量FLOPS(GFLOP/sec) | 26 | 117 | 63 | 4.6 | 2.5 |
峰值单精度SIMD FLOPS(GFLOP/sec) | 102 | 311-933 | 515-1344 | 3.0-9.1 | 6.6-13.1 |
SP 1相加或相乘 | N/A | 311 | 515 | 3.0 | 6.6 |
SP 1指令融合乘法-加法 | N/A | 622 | 1344 | 6.1 | 13.1 |
特殊的SP双问题融合乘加乘 | N/A | 933 | N/A | 9.1 | - |
峰值双精度SIMD FLOPS(GFLOP/sec) | 51 | 78 | 515 | 1.5 | 10.1 |
下图中的Core i7 960和GTX 280的曲线说明了计算机的差异。GTX280不仅具有更高的内存带宽和双精度浮点性能,而且它的双精度脊点也位于左侧。GTX 280的双精度脊点为0.6,而Core i7为3.1。如上所述,曲线的脊点越靠近左侧,就越容易达到峰值计算性能。对于单精度性能,两台计算机的脊点都会向右移动,因此很难达到单精度性能的顶点。请注意,内核的算术强度基于进入主内存的字节,而不是进入缓存的字节。因此,如上所述,如果大多数引用真的到了缓存,缓存可以改变特定计算机上内核的算术强度。还请注意,这两种架构中的单位步长访问都使用此带宽,GTX 280和Core i7上的真实聚集分散地址可能会更慢。
这些曲线在顶行显示双精度浮点性能,在底行显示单精度性能。(DP FP性能上限也在最下面一行,以提供透视图。)左侧的Core i7 960的DP FP性能峰值为51.2 GFLOP/sec,SP FP峰值为102.4 GFLOP/sec,峰值内存带宽为16.4 GBytes/sec。NVIDIA GTX 280的DP FP峰值为78 GFLOP/秒,SP FP峰值为624 GFLOP//秒,内存带宽为127 GB/秒。左侧的垂直虚线表示0.5 FLOP/字节的算术强度,在Core i7上,内存带宽限制为不超过8 DP GFLOP/sec或8 SP GFLOP/sec。右侧的垂直虚线的算术强度为4 FLOP/字节。在Core i7上,它的计算速度仅限于51.2 DP GFLOP/sec和102.4 SP GFLOP/sec,在GTX 280上,它仅限于78 DP GFLOp/sec和624 SP GFLOp/sec。要在Core i8上达到最高的计算速度,需要使用所有四个核心和SSE指令,并使用相同数量的乘法和加法。对于GTX 280,需要在所有多线程SIMD处理器上使用融合乘-加指令。
研究人员通过分析最近提出的四个基准套件的计算和内存特性来选择基准程序,然后“制定了一组捕获这些特性的吞吐量计算内核”。下图显示了性能结果,数字越大意味着速度越快,曲线有助于解释本案例研究中的相对性能。
鉴于GTX 280的原始性能规格从2.5倍慢(时钟速率)到7.5倍快(每个芯片的内核数)不等,而性能从2.0倍慢(Solv)到15.2倍快(GJK)不等,Intel研究人员决定找出差异的原因:
- 内存带宽。GPU具有4.4倍的内存带宽,有助于解释为什么LBM和SAXPY的运行速度分别为5.0和5.3倍,它们的工作集有数百兆字节,因此不适合Core i7缓存,为了集中访问内存,它们故意不使用缓存阻塞(cache blocking),曲线的坡度解释了它们的性能。SpMV也有一个大的工作集,但它的运行速度仅为1.9倍,因为GTX 280的双精度浮点运算速度仅为Core i7的1.5倍。
- 计算带宽。剩下的五个内核是计算密集的:SGEMM、Conv、FFT、MC和Bilat,GTX的速度分别为3.9、2.8、3.0、1.8和5.7倍。前三个使用单精度浮点运算,GTX 280单精度运算速度快3到6倍,MC使用双倍精度,这解释了为什么DP性能只快1.5倍,所以它只快1.8倍。Bilat使用GTX 280直接支持的超越函数,Core i7将三分之二的时间用于计算Bilat的超越函数,因此GTX 280的速度要快5.7倍。这一观察有助于指出硬件支持工作负载中发生的操作的价值:双精度浮点运算,甚至可能是超验运算。
- 缓存优势。光线投射(RC)在GTX上的速度仅为1.6倍,因为Core i7缓存的缓存阻塞阻止了它成为内存带宽限制,就像在GPU上一样,缓存阻塞也可以帮助搜索。如果索引树很小,可以放入缓存,那么Core i7的速度是它的两倍,较大的索引树使其内存带宽受限,总体而言,GTX 280的搜索速度快1.8倍,缓存阻塞也有助于排序。虽然大多数程序员不会在SIMD处理器上运行排序,但它可以用一个称为拆分的1位排序原语来编写,但分割算法执行的指令比标量排序多得多,所以Core i7的运行速度是GTX 280的1.25倍。请注意,缓存也有助于Core i7上的其他内核,因为缓存阻塞允许SGEMM、FFT和SpMV成为计算绑定。这一观察再次强调了缓存阻塞优化的重要性。
- 分散-聚集。如果数据分散在主存储器中,多媒体SIMD扩展几乎没有帮助,只有当对数据的访问在16字节边界上对齐时,才能获得最佳性能。因此,GJK从Core i7上的SIMD中获得的好处很少。如上所述,GPU提供了向量架构中的聚集-分散寻址,但大多数SIMD扩展中都忽略了这种寻址,存储器控制器甚至一起批量访问同一DRAM页。这种组合表明GTX 280以惊人的15.2倍于Core i7的速度运行GJK,比上上图中的任何单个物理参数都要大。这一观察结果强化了SIMD扩展中缺少的矢量和GPU架构的聚集散射的重要性。
- 同步。同步性能受到原子更新的限制,尽管Core i7具有硬件获取和增量指令,但原子更新占Core i7总运行时间的28%。因此,Hist在GTX 280上的速度仅为1.7倍,Solv在少量计算中解决了一批独立约束,然后进行了屏障同步。Core i7得益于原子指令和内存一致性模型,即使不是以前对内存层次结构的所有访问都已完成,也能确保正确的结果。在没有内存一致性模型的情况下,GTX 280版本从系统处理器启动了一些批处理,导致GTX 280的运行速度是Core i7的0.5倍。这一观察结果指出了同步性能对于某些数据并行问题的重要性。
令人惊讶的是,Intel研究人员选择的内核发现的Tesla GTX 280中的弱点,已经在Tesla的后续架构中得到了解决:Fermi具有更快的双精度浮点性能、更快的原子运算和缓存。同样有趣的是,比SIMD指令早了几十年的向量架构的聚集-分散支持对于这些SIMD扩展的有效有用性非常重要,有些人在比较之前就已经预测到了这一点。Intel的研究人员指出,14个内核中的6个内核可以更好地利用SIMD,在Core i7上提供更高效的聚集-分散支持。这项研究也肯定了缓存阻塞的重要性。
19.9.8 NVidia Tesla架构
下图显示了Tesla架构,让我们从图的顶部开始解释。主机CPU通过专用总线向图形处理器发送命令和数据序列,然后,专用总线将一组命令和数据传输到GPU上的缓冲区,随后GPU的单元处理信息。在下图中,工作从上到下流动。GPU本质上是一组非常简单的有序内核,此外,它还有大量额外的硬件来协调复杂任务的执行,并将工作分配给一组核心。GPU还支持多级内存层次结构,并具有专门执行少数图形特定操作的专用单元。
NVIDIA Tesla架构。
19.9.8.1 工作分配
GPU可以分配三种工作:顶点处理、像素处理和常规计算工作。GPU定义自己的汇编代码,使用PTX和SASS指令集,这些指令集中的每个指令都在GPU上执行基本操作,它使用寄存器操作数或内存操作数。与CPU不同,GPU中寄存器文件的结构通常不暴露于软件,程序员需要使用无限数量的虚拟寄存器,GPU或设备驱动程序将它们映射到实际寄存器。
现在,对于处理顶点,低级图形软件向GPU发送一系列装配指令。GPU有一个硬件汇编程序,它生成二进制代码,并将其发送到一个专用的顶点处理单元,该单元协调和分配GPU内核之间的工作。或者,CPU可以向GPU发送像素处理操作,GPU执行光栅化、片段处理和深度缓冲的过程。GPU中的一个专用单元为这些操作生成代码片段,并将其发送到像素处理单元,该像素处理单元将工作项分配给GPU核心集。第三个单元是计算工作分配器,它接受CPU的常规计算任务,例如添加两个矩阵或计算两个向量的点积。程序员指定一组子任务,计算工作分配引擎的作用是将这些子任务集发送到GPU中的核心。
在这个阶段之后,GPU或多或少地忽略了指令的来源,注意,这部分工程是GPU成功背后的关键贡献。设计师已经成功地将GPU的功能分为两层,第一层特定于操作类型(图形或通用)。在此阶段,每个流水线的作用是将特定的操作序列转换为一组通用的操作,这样无论高级操作的性质如何,都可以使用相同的硬件单元。现在来看看包含计算引擎的GPGPU的后半部分。
19.9.8.2 GPU计算引擎
GeForce 8800 GPU有128个内核。核心小组分为8组,每个组称为TPC(纹理/处理器集群),每个TPC包含两个SM(流式多处理器)。此外,每个SM包含8个称为流处理器(SP)的核心,每个SP都是一个简单的有序内核,具有符合IEEE 754的浮点ALU、分支和内存访问单元。除了一组简单的内核外,每个SM都包含一些专用的内存结构。这些内存结构包含常量、纹理数据和GPU指令。所有SP都可以并行执行一组指令,并且彼此紧密同步。
19.9.8.3 互连网络、DRAM模块、二级缓存和ROP
8个TPC通过互连网络连接到一组缓存、DRAM模块和ROP(光栅操作处理器)。SM包含一级缓存,在缓存未命中时,SP核心通过NOC访问相关的二级缓存库。在GPU的情况下,二级缓存是在存储体(bank)级别上分割的共享缓存,在二级缓存之下,GPU有一个大的外部DRAM内存。GeForce 8800有384个引脚可连接到外部DRAM模块,该组引脚分为6组,每组包含64个引脚。物理内存空间也被分成6个部分,跨越6个组。光栅化操作通常需要一些专门的处理例程,不幸的是,这些例程在TPC上运行效率低下,因此GeForce 8800芯片具有6个ROP,每个ROP处理器每个周期最多可以处理4个像素,它主要对像素的颜色进行插值,并执行颜色混合操作。
19.9.9 NVidia RTX 4090架构和特性
前不久,NVIDIA宣布推出Ada Lovelace GeForce一代时,曾有过一些大胆的声明,光线跟踪性能的有效翻倍,在测试了一系列流行的渲染引擎之后,确实如此。
RTX 4090 GPU芯片结构。
Ada Lovelace一代带来了第四代Tensor磁芯和改进的光流。在创建过程中,这些功能加速了降噪等功能,而在游戏应用中,它们通过DLSS 3.0进行了升级。在光线跟踪核心方面,Ada Lovelace推出了第三代实现,并在很大程度上提供比Ampere一代提高2倍的性能。
其他值得注意的功能是Shader Execution Reordering,它进一步提高了光线跟踪性能,包括在游戏中,其中一个例子显示《赛博朋克2077》中有44%的提升。此外,Intel率先推出AV1加速GPU编码器,NVIDIA也紧随其后,Ada Lovelace也推出了一款。有趣的是,NVIDIA提供了板载双编码器,它声称这将使编码时间减半。我们将通过即将推出的完整创作者性能外观来探索这一点。
在开始了解NVIDIA最新旗舰的渲染性能之前,先看看NVIDIA官方正版的硬件参数:
GPU型号 | 核心数 | 最大频率 | 峰值FP32 | 内存 | 带宽 | 总功率 |
---|---|---|---|---|---|---|
RTX 4090 | 16,384 | 2,520 | 82.6 TFLOPS | 24GB | 1008 GB/s | 450W |
RTX 4080 16GB | 9,728 | 2,510 | 48.8 TFLOPS | 16GB | 717 GB/s | 320W |
RTX 3090 Ti | 10,752 | 1,860 | 40 TFLOPS | 24GB | 1008 GB/s | 450W |
RTX 3080 Ti | 10,240 | 1,670 | 34.1 TFLOPS | 12GB | 912 GB/s | 350W |
RTX 3070 Ti | 6,144 | 1,770 | 21.7 TFLOPS | 8GB | 608 GB/s | 290W |
RTX 3060 Ti | 4,864 | 1,670 | 16.2 TFLOPS | 8GB | 448 GB/s | 200W |
RTX 4090配备了这一代的第一款Ada Lovelace GPU:AD102。但值得注意的是,这款旗舰卡中使用的芯片并不是全核,尽管其规格表已经非常庞大。其核心是16384个CUDA内核,分布在128个流式多处理器(SM)上,意味着比RTX 3090 Ti的GA102 GPU(其本身就是完整的Ampere核心)增加了52%。
上:RTX 4090内的AD102结构;下:完整的AD102 GPU结构。
GA102和AD102架构对比图。
完整的AD102芯片包括18432个CUDA核心和144个SM,也意味着将看到144个第三代RT核心和576个第四代Tensor核心。如果英伟达愿意,RTX 4090 Ti甚至Titan都有足够的空间。
Ada Lovelace和Ampere架构的SM对比图。
内存变化不大,同样是24GB的GDDR6X以21Gbps的速度运行,可提供1008GB/秒的内存带宽。下表是GeForce RTX 4090和GeForce RTX 3090 Ti的部分参数对比图:
GeForce RTX 4090 | GeForce RTX 3090 Ti | |
---|---|---|
架构 | Ada Lovelace | Ampere |
CUDA核心 | 16,432 | 10,752 |
SM | 128 | 84 |
RT核心 | 128 | 84 |
Tensor核心 | 512 4代 | 336 3代 |
ROP | 176 | 112 |
最大频率 | 2,520MHz | 1,860MHz |
内存 | 24GB GDDR6X | 24GB GDDR6X |
内存速度 | 21Gbps | 21Gbps |
内存带宽 | 1,008GB/s | 1,008GB/s |
总线宽 | 384 | 384 |
L1 | L2缓存 | 16,384KB | 73,728KB | 10,752KB | 6,144KB |
制作工艺 | 5nm TSMC | 8nm Samsung |
晶体管 | 763亿 | 283亿 |
芯片面积 | 608.5mm² | 628.5mm² |
总功率 | 450W | 450W |
在方程式的原始着色器方面,事情也没有从Ampere架构中真正发展到那么远。每个SM仍然使用相同的64个专用FP32单元,但具有64个单元的辅助流,可以根据需要在浮点和整数计算之间进行拆分,与Ampere引入的相同。
当查看RTX 3090和RTX 4090之间的相对性能差异时,可以从光栅化的角度看到这两种架构有多相似。
如果忽略光线追踪和放大,则相应的性能提升仅略高于AD102 GPU中额外的CUDA内核数量。尽管业绩增长“略高于”相应水平,但确实表明在这一水平上存在一些差异。
部分原因在于英伟达用于Ada Lovelace GPU的新4纳米生产工艺。与Ampere的8纳米三星工艺相比,据说台积电制造的4N工艺在相同功率下提供了两倍的性能,或者在相同性能下提供了一半的性能。
这意味着英伟达可以在时钟速度方面具有超强的攻击性,RTX 4090的提升时钟为2520MHz。实际上,我们在测试中看到了Founders Edition卡的平均频率为2716MHz,比上一代的RTX 3090快了整整1GHz。
而且,由于工艺的缩减,英伟达与台积电合作的工程师已经在AD102核心中塞进了惊人的763亿个晶体管。考虑到608.5mm²的Ada GPU包含的晶体管比GA102硅的283亿晶体管还要多,它可能比628.4mm²的Ampere芯片小得多。
事实上,英伟达能够继续将数量不断增加的晶体管塞进单片芯片中,并仍然不断缩小其实际管芯尺寸,这证明了该领域先进工艺节点的威力。作为参考,RTX 2080 Ti的TU102芯片面积为754mm²,仅容纳186亿个12nm晶体管。但并不意味着单片GPU可以永远继续,不受限制。GPU的竞争对手AMD承诺将于11月推出新的RDNA 3芯片,转而使用图形计算芯片。考虑到AD102 GPU的复杂性仅次于先进的814mm²Nvidia Hopper硅的800亿晶体管,它肯定是一种昂贵的芯片。然而,较小的计算芯片应该会降低成本,提高产量。
更多参数规格和特性如下所示:
但至少到目前,暴力的整体方法仍在为英伟达带来回报。
当想要更高的速度,并且已经尽可能多地安装了先进的晶体管时,还能做什么?答案是可以在包中添加更多的缓存,是AMD在其Infinity Cache中取得的巨大效果,尽管英伟达不一定会采用一些花哨的新品牌方法,但它在Ada核心中增加了大量L2缓存。
上一代GA102包含6144KB的共享二级缓存,位于其SM的中间,Ada将其增加16倍,以创建98304KB的二级缓存池,供AD102 SM使用。对于RTX 4090版本的芯片,其容量降至73728KB,但仍有大量缓存。每个SM的L1数量没有变化,但因为现在芯片内总共有更多的SM,这也意味着与Ampere相比,L1缓存的数量也更大。
但如今,光栅化并不是GPU的全部。当图灵首次在游戏中引入实时光线追踪时,可能会有这样的感觉,现在它几乎已经成为PC游戏的标准组成。升级也是如此,因此架构如何接近PC游戏的这两大支柱,对于整体理解设计至关重要。
如今的所有三家显卡制造商都专注于光线追踪性能以及升级技术的复杂性,俨然成为他们之间的一场全新战争。
RTX 4090的规格为450W,是一款耗电的GPU,因此PSU越大越好。NVIDIA规定的最低功率为850W,在进行密集的3DMark测试时,已经达到了650W的峰值。RTX 4090需要3个8针电源连接器,或者一个带有新的PCIe 5支持PSU的电源连接器。由于测试平台的PSU刚好符合最低要求,将在未来转向更大的PSU。
关于RTX 4090的冷却器,其设计与上一代RTX 3090相似,但发动机罩下的改进有利于温度。最新型号的风扇更大,同时减少了叶片数量。经过对RTX 4090进行了足够的测试,在3DMark Fire Strike Ultra测试期间,它比3090(总功率650W)多了100W。
阐述完它的硬件规格,下面聊聊其渲染特性。
Ada流式多处理器中发生了真正的变化。光栅化组件可能非常相似,但第三代RT Core已经发生了巨大变化。前两代RT Core包含一对专用单元,即长方体相交引擎和三角体相交引擎,在计算光线跟踪核心的边界体积层次(BVH)算法时,这两个单元从SM的其余部分中提取了大量RT工作量。
Ada引入了另外两个独立的单元来卸载SM的更多工作: Opacity Micromap Engine(OME)和Displaced Micro-Mesh Engine。第一种方法在处理场景中的透明度时大大加快了计算速度,第二种方法旨在分解几何上复杂的对象,以减少完成整个BVH计算所需的时间。
左:Ampere三角形相交示意图,其中射线可能会多次击中浅黄色的三角形,每次击中都会触发一次anyhit着色器。右:Ada的OPACITY MICRO MAPS Shading(OMMS)的纹理渲染技术,配合OME可以显著减少Alpha遍历后的计算。透过 OMMS 技术,射线遇到上图中浅蓝色部分的时候直接忽略掉 anyhit 计算,从而显著提升这类物件的计算量。
Displaced Micro-Mesh Engine工作机制示意图。
除此之外,Nvidia还称之为“GPU的一项重大创新,就像20世纪90年代CPU的无序执行一样”。创建了着色器执行重新排序(SER)来切换着色工作负载,从而允许Ada芯片通过实时重新调度任务来大大提高图形管线的效率。
Intel一直在为其炼金术师GPU(在新选项卡中打开)开发类似的功能,线程排序单元,以帮助光线跟踪场景中的发散光线。据报道,它的设置不需要开发人员的输入。目前,Nvidia需要一个特定的API来将SER集成到开发者的游戏代码中,正在与微软和其他公司合作,将该功能引入DirectX 12和Vulkan等标准图形API中。
最后来看看DLSS 3.0,它的王牌:帧生成,DLSS 3现在不仅会升级,还会自己创建整个游戏帧。不一定是从头开始,而是通过使用AI和深度学习的力量,对下一帧的外观进行最佳猜测,如果真的要渲染它,然后它在下一个真正渲染的帧之前注入AI生成的帧。
这是巫毒,是黑魔法,是黑暗的艺术,而且相当壮观。它使用第四代张量核内的增强型硬件单元(称为光流单元)进行所有这些飞行计算,然后利用神经网络将先前帧中的所有数据、场景中的运动矢量和光流单元拉到一起,以帮助创建一个全新的帧,该帧还能够包括光线追踪和后期处理效果。
英伟达与DLSS升级(现在称为DLSS超级分辨率)一起工作时表示,在某些情况下,AI将通过升级生成初始帧的四分之三,然后使用帧生成生成整个第二帧。总的来说,它估计AI正在创建所有显示像素的八分之七。它在3DMark Time Spy Extreme的得分是大安培核心的两倍,在光线追踪或DLSS加入之前,原始硅提供的4K帧速率也是《赛博朋克2077》的两倍。
赛博朋克2077的对比数据如下:
在能效方面,4090的平均功率高于3090约18%,每瓦特的性能是3090的1.75倍(1080P),平均温度比3090低约4.5%。
19.9.10 谬论与陷阱
GPU的发展和变化如此之快,以至于出现了许多谬误和陷阱,此节介绍其中一部分。
19.9.10.1 谬论:GPU只是SIMD向量多处理器
很容易得出这样的错误结论:GPU只是简单的SIMD向量多处理器。GPU有一个SPMD风格的编程模型,程序员可以编写一个在多个线程实例中使用多个数据执行的程序,但这些线程的执行不是单纯的SIMD或向量,实际上它是单指令多线程(SIMT)。每个GPU线程都有自己的标量寄存器、线程专用内存、线程执行状态、线程ID、独立执行和分支路径以及有效的程序计数器,并且可以独立地寻址内存。尽管当用于线程的PC相同时,一组线程(如32个线程的warp)执行效率更高,但不是必需的,所以多处理器并非纯粹的SIMD。线程执行模型是MIMD,具有屏障同步和SIMT优化。如果单个线程加载/存储内存访问也可以合并为块访问,则执行效率更高,但不是绝对必要。在纯SIMD向量架构中,不同线程的内存/寄存器访问必须以规则向量模式对齐,GPU对寄存器或存储器访问没有这种限制,然而,如果线程的warp访问局部数据块,则执行效率更高。
与纯SIMD模型相比,SIMT GPU可以同时执行多个线程warp。在图形应用中,可能有多组顶点程序、像素程序和几何程序同时在多处理器阵列中运行,计算程序也可以在不同的warp中同时执行不同的程序。
19.9.10.2 谬论:GPU性能的增长速度不能超过摩尔定律
摩尔定律只是一个速率,不是任何其他速率的“光速”限制。摩尔定律描述了一种预期,即随着时间的推移,随着半导体技术的进步和晶体管的变小,每个晶体管的制造成本将呈指数下降。换言之,在制造成本不变的情况下,晶体管的数量将成倍增加。戈登·摩尔(Gordon Moore)预测,在相同的制造成本下,每年将提供大约两倍的晶体管数量,后来将其修改为每2年增加一倍。尽管摩尔在1965年做出了最初的预测,当时每个集成电路只有50个组件,但事实证明这一预测非常一致。晶体管尺寸的减小在历史上也有其他好处,例如每个晶体管的功率更低,恒定功率下的时钟速度更快。
越来越多的晶体管被芯片设计师用来制造处理器、内存和其他组件。一段时间以来,CPU设计者使用额外的晶体管以类似摩尔定律的速度提高处理器性能,以至于许多人认为每18-24个月处理器性能增长两倍是摩尔定律。事实上,事实并非如此。
微处理器设计人员将一些新晶体管用于处理器核心,改进了架构和设计,并通过流水线实现了更高的时钟速度。其余的新晶体管用于提供更多的缓存,以加快内存访问速度。相比之下,GPU设计者几乎不使用任何新晶体管来提供更多缓存,大多数晶体管用于改进处理器内核和添加更多处理器内核。GPU通过四种机制加快速度:
- GPU设计人员通过应用成倍增加的晶体管来构建更并行、从而更快的处理器,直接获得摩尔定律的好处。
- GPU设计者可以随着时间的推移改进架构,提高处理效率。
- 摩尔定律假设成本不变,因此,如果花费更多的钱购买更大的芯片和更多的晶体管,显然可以超过摩尔定律的比率。
- GPU内存系统通过使用更快的内存、更宽的内存、数据压缩和更好的缓存,以几乎与处理速度相当的速度增加了有效带宽。
这四种方法的结合在历史上允许GPU性能定期翻倍,大约每12到18个月一次,超过了摩尔定律的速度,已经在图形应用程序上演示了大约10年,并且没有明显放缓的迹象。最具挑战性的限速器似乎是内存系统,但竞争性创新也在迅速推进。
19.9.10.3 谬论:GPU仅渲染3D图形不能做通用计算
GPU用于渲染3D图形以及2D图形和视频。为了满足图形软件开发人员在图形API的接口和性能/功能要求中所表达的需求,GPU已经成为大规模并行可编程浮点处理器。在图形领域,这些处理器通过图形API和晦涩难懂的图形编程语言(OpenGL和Direct3D中的GLSL、Cg和HLSL)进行编程。然而,没有什么可以阻止GPU架构师将并行处理器内核暴露给没有图形API或神秘图形语言的程序员。
事实上,Tesla架构的GPU系列通过一个名为CUDA的软件环境来暴露处理器,该软件环境允许程序员使用C语言和C++开发通用应用程序。GPU是图灵完备的处理器,因此它们可以运行CPU可以运行的任何程序,尽管可能不太好,也许更快。
19.9.10.4 谬论:GPU无法快速运行双精度浮点程序
在过去,GPU根本无法运行双精度浮点程序,除非通过软件仿真,但软件仿真一点都不快。GPU已经从索引算术表示(颜色查找表)到每个颜色分量8位整数,再到定点算术,再到单精度浮点,后又增加了双精度。现代GPU几乎所有计算都采用单精度IEEE浮点运算,并且开始使用双精度运算。
GPU可以支持双精度浮点和单精度浮点,只需少量额外成本。如今,双精度运行速度比单精度运行速度慢,大约慢5到10倍。对于增加的额外成本,随着更多应用的需要,双精度性能可以在阶段中相对于单精度提高。
19.9.10.5 谬论:GPU不能正确执行浮点运算
至少在Tesla体系结构系列处理器中,GPU执行IEEE 754浮点标准规定的单精度浮点处理。因此,就精度而言,GPU与任何其他符合IEEE 754的处理器一样。
如今的GPU没有实现标准中描述的某些特定功能,例如处理非规范化的数字和提供精确的浮点异常,但Tesla T10P GPU提供了完整的IEEE舍入、融合乘加和双精度非规范化数字支持。
19.9.10.6 谬论:O(n)算法很难加速
无论GPU处理数据的速度有多快,向设备传输数据和从设备传输数据的步骤可能会限制具有O(n)复杂性的算法的性能(每个数据的工作量很小)。当使用DMA传输时,PCIe总线上的最高传输速率约为48 GB/秒,而对于非DMA传输则稍低,相比之下,CPU对系统内存的访问速度通常为8–12 GB/秒。例如矢量加法,将受到输入到GPU的传输和计算返回输出的限制,有三种方法可以克服传输数据的成本:
- 尽量将数据留在GPU上,而不是在复杂算法的不同步骤中来回移动数据。CUDA故意在两次启动之间将数据单独留在GPU中以支持这一点。
- GPU支持复制入、复制出和计算的并发操作,因此数据可以在设备进行计算时流入和流出设备。该模型对于任何可以在到达时处理的数据流都很有用。例如视频处理、网络路由、数据压缩/解压缩,甚至更简单的计算,如大向量数学。
- 将CPU和GPU一起使用,通过将工作的子集分配给每一个来提高性能,将系统视为异构计算平台。CUDA编程模型支持将工作分配给一个或多个GPU,以及在不使用线程的情况下继续使用CPU(通过异步GPU功能),因此保持所有GPU和CPU同时工作以更快地解决问题相对简单。
19.9.10.7 陷阱:只需使用更多的线程来覆盖更长的内存延迟
CPU内核通常被设计为以全速运行单个线程。要全速运行,每个指令及其数据都需要在该指令运行时可用。如果下一条指令未就绪或该指令所需的数据不可用,则该指令无法运行,处理器将停滞。外部内存与处理器相距较远,因此从内存中获取数据需要许多周期的浪费执行。
因此,CPU需要大型局部缓存来保持运行而不停滞,内存延迟很长,因此可以通过努力在缓存中运行来避免。在某些情况下,程序工作集的需求可能比任何缓存都大,一些CPU使用多线程来容忍延迟,但每个内核的线程数通常被限制在一个小数目。
GPU策略不同。GPU内核设计为同时运行多个线程,但一次只能从任何线程执行一条指令。另一种说法是GPU缓慢地运行每个线程,但总体上高效地运行线程。每个线程都可以容忍一定的内存延迟,因为有其他线程可以运行。
这样做的缺点是需要多个多线程来覆盖内存延迟。此外,如果内存访问在线程之间分散(scattered)或不是相关的(correlated),那么内存系统在响应每个单独的请求时会逐渐变慢,最终即使是多个线程也无法覆盖延迟。因此,陷阱在于,对于“只使用更多线程”策略来覆盖延迟,必须有足够的线程,并且线程必须在内存访问的位置方面表现良好。
19.10 电源
本节着重阐述移动设备的电源技术。
智能手机已成为我们日常生活中不可替代的商品。无论是职业还是个人生活,每项任务都以某种方式或其他方式与这些设备相关。为了满足我们日益增长的依赖,这些智能手机每天都在变得更加强大。强大的处理器、更多的存储空间和改进的摄像头是每个买家都想要的功能。
除了操作系统,消费者还使用各种应用程序,这些应用程序使用我们设备的不同传感器和处理能力。所有这些过程都需要一个电源来运行,移动设备中则是一个电池。这些电池必须不时充电,以保持流程正常运行。更长的电池寿命是选择智能手机的另一个重要标准,与电池寿命优化相关的技术发展速度与智能手机行业的其他垂直行业不同。
通过硬件和软件技术可以提高智能手机的电池寿命。改变硬件可能意味着安装更大的电池,但也意味着增加智能手机的尺寸。设计高效的电源管理单元和高效的集成电路(IC)是一种可行的解决方案。此外,操作系统中管理电池密集型应用程序和明智使用可用电池的软件改进也被视为该问题的另一个潜在解决方案。
下图是一款便携式产品的电源管理:
智能手机的耗电组件多种多样,常见的如下图所示:
19.10.1 电源硬件
不同的嵌入式系统、芯片、处理器和传感器集成在一起,同步工作,使这些移动设备变得智能。它的每个硬件设备在运行时都会消耗电力。在所有这些电子模块中,收发器模块消耗最大的功率,因为它在很长时间内保持活动状态以接收传入的分组。已经讨论了各种软件技术来优化这些分组的数据传输。数字信号处理器(DSP)是这些收发器模块的关键部件,它处理大量数据以供多媒体使用,降低DSP的电源电压是降低功耗的直接方法。
为了延长电池寿命,智能手机的DSP在通话期间需要低功耗和高吞吐量乘法累加(MAC),在等待期间需要低功率间歇操作。1V多阈值CMOS电路通过简单的并行架构和使用嵌入式处理器的电源管理技术满足这些要求,嵌入式处理器与适用于电源控制的改进DFF一起使用。
除了处理器和收发器,屏幕是电池电量的另一个主要消耗源。需要背光的LED屏幕更耗电,因此可以被更省电的显示器(如OLED)取代,后者耗电更少。与LED和LCD显示器不同,OLED不需要背光,OLED中的每个像素都有自己独立的颜色和光源。因此,OLED上的黑色图像将是完全黑色的,但LED和LCD的情况并非如此。
研究表明,随着时间的推移,电池寿命的下降也可能是由于聚偏氟乙烯(PVDF)。PVDF是一种用于防止电池中石墨阳极剥落的粘合剂,不导电,并且由于粘附率差而溶解在电解质中。还存在一种新的n型共轭共聚物——双亚氨基并萘醌对亚苯基(BP)粘合剂,其性能优于传统的PVDF基粘合剂,延长了电池寿命,并防止了电池老化时的退化。
MC13892的电源结构图。包含电池、接口控制等元件。
MC13892电源管理和用户接口结构图。
研究人员还提出了一种动态电源管理单元(Power Management Unit,PMU),它在智能手机上运行不同应用程序时,收集处理器和输入/输出设备的不同参数的信息,然后PMU将基于收集的信息提出预测功率感知管理方案。
一款名为nRF5340中的电源和时钟管理系统针对超低功耗应用进行了优化,以确保最大功率效率。电源和时钟管理系统的核心是电源管理单元(PMU),如下图所示。
PMU在任何给定时间自动跟踪系统中不同组件所需的电源和时钟资源。为了实现可能的最低功耗,PMU通过评估电源和时钟请求、自动启动和停止时钟源以及选择调节器操作模式来优化系统。
PMU一般有系统开启模式(System ON)、系统关闭(System OFF)、强制关闭(Force OFF)3个模式,具体详情如下所述。
系统开启(System ON)模式是通电复位后的默认操作模式。在System ON(系统开启)中,所有功能块(如CPU和外围设备)都可以处于IDLE(空闲)或RUN(运行)状态,取决于软件设置的配置和正在执行的应用程序的状态。网络核心的CPU和外围设备可以处于空闲状态、运行状态或强制关闭模式。
PMU可以根据电源要求打开和关闭适当的内部电源。外围设备的电源需求与其活动级别直接相关,当触发特定任务或生成事件时,活动级别会增加或减少。
-
电压和频率缩放。nRF5340自动调整内部电压以优化功率效率。一些配置选项要求更高的内部电压,被视为功耗的增加。这些配置如下:
- 将应用程序核心时钟的频率设置为128 MHz。当CPU休眠时,例如在执行WFI(等待中断)或WFE(等待事件)指令后,也会观察到此模式下的功耗增加。通过在进入CPU休眠之前将应用程序核心的时钟配置为64 MHz,可以降低CPU和外围设备处于空闲状态休眠时系统开启期间的功耗。
- 使用96 MHz时钟频率的QSPI。
- 使用USB外围设备。
- 调试时。
- 使用VREQCTRL请求VREGRADIO电源上的额外电压-电压请求控制。
-
电源子模式(Power submode)。在系统开启模式下,当CPU和所有外围设备处于空闲状态时,系统可以处于两种电源子模式之一。
电源子模式包括:- 恒定延迟。在恒定延迟模式下,CPU唤醒延迟和PPI任务响应将保持恒定并保持在最小值,是由一组始终启用的资源保护的。与低功耗模式相比,具有恒定和可预测的延迟的优势是以增加功耗为代价的,通过触发CONSTRAT任务选择恒定延迟模式。
- 低功耗。在低功率模式下,自动电源管理系统选择最省电的电源选项,实现最低功率是以CPU唤醒延迟和PPI任务响应的变化为代价的。通过触发LOWPWR任务选择低功率模式。
当系统进入system ON(系统开启)时,默认为Low power(低功率)子模式。
系统关闭(System OFF)是系统可以进入的最深省电模式。在此模式下,系统的核心功能关闭,所有正在进行的任务都将终止。使用寄存器SYSTEMOFF将设备置于System OFF(系统关闭)模式。以下操作将从System OFF(关闭)启动唤醒:
- GPIO外围设备生成的DETECT信号。
- LPCOMP外围设备生成的ANADETECT信号。
- 由NFCT外围设备产生的SENSE信号在现场唤醒。
- 检测到VBUS引脚上的有效USB电压。
- 调试会话已启动。
- A引脚复位。
当设备从系统关闭状态唤醒时,将执行系统重置。根据外围VMC-易失性存储器控制器中的RAM保留设置,一个或多个RAM部分可以保留在系统关闭状态。在进入系统关闭之前,当进入系统关闭时,启用EasyDMA的外围设备不得处于活动状态。还建议网络核心处于空闲状态,意味着外围设备已停止,CPU处于空闲状态。
强制关闭(Force-OFF)模式仅适用于网络核心。
应用程序核心使用寄存器接口RESET-RESET控件强制网络核心进入强制关闭模式。在此模式下,网络核心被停止,以实现可能的最低功耗。当网络核心处于强制关闭模式时,只有应用程序核心可以释放该模式,导致网络核心唤醒并再次启动CPU。
在应用程序核心将网络核心设置为强制关闭模式之前,建议网络核心处于IDLE状态,如下所示:
- 所有外围设备均已停止。
- 使用VREQCTRL-电压请求控制取消VREGRADIO电源上的附加电压。
- CPU处于IDLE状态,这意味着它正在运行WFI或WFE指令。
当网络核心从强制关闭模式唤醒时,它将被重置。根据外围VMC-易失性存储器控制器中的RAM保留设置,可以在强制关闭模式下保留几个RAM部分。
19.10.2 电源软件
具有更高处理能力和更快互联网连接的现代智能手机的巨大普及也增加了Android和iOS中数据和硬件密集型应用程序的数量,WhatsApp、Instagram、Skype等应用程序不仅需要CPU资源,还需要全天候的互联网连接。研究表明,在空闲状态下,互联网使用约占耗电量的62%。此外,与Wi-Fi相比,当频繁交换小尺寸数据包时,3G/4G消耗更多的电池。
数据压缩、数据包聚合和批量调度等多种软件技术可用于优化电池寿命。智能手机上不同应用程序的随机数据传输会消耗更多的电池,因此,可以使用批处理调度机制通过应用程序重复传输数据来最大化睡眠时间并最小化唤醒频率。
从3G/4G到Wi-Fi的数据卸载是提高电池寿命的另一种有效方式,因为Wi-Fi在数据传输方面比3G/4G更高效。另一种软件技术是将更高的计算任务(如CPU密集型软件)卸载到云上进行计算。该策略可用于在移动设备上运行Office 365和MATLAB等软件,但这会增加云与设备之间的通信成本。应用状态代理(ASP)是另一种技术,其中不仅使用CPU资源而且还使用Internet数据的后台应用程序被抑制并传输到另一设备上,并且仅在请求时才被带到设备上。
智能手机行业在处理能力和其他功能方面的进步速度远快于电池,研究人员现在正专注于通过软件和硬件手段来有效管理可用电池能量的电源管理技术,上述不同技术正被用于将智能手机的电池寿命提高多倍。
将能耗分配给并发运行的应用程序具有挑战性,因为功率状态传输有时是不同应用程序动作的累积结果。
例如,假设当每秒发送N个分组时,Wi-Fi接口从低功率状态传输到高功率状态。现在假设两个应用程序以每秒N/2个数据包的速度传输,导致Wi-Fi接口进入高功率状态。类似地,设想一个应用程序以每秒N个数据包的速度传输,另一个应用以每秒9N个数据。在这两种情况下,Wi-Fi接口都处于高功率状态,但不清楚如何为每个应用程序分配功率使用。在第一种情况下,两个应用程序都不会单独触发高功率状态,因此为它们充电有意义吗?在第二种情况下,两个应用程序都会触发高功率状态,因此应该大致相同的充电量,但应该是多少?
一种可能的解决方案是根据应用程序的工作负载在每个应用程序之间分配组件功率,意味着在第一种情况下,每个应用程序将被分配高功率状态功率的一半,而在第二种情况下一个应用程序将分配1/10的功率,另一个将分配9/10的功率。该解决方案具有有利的性质,即应用程序功耗的总和等于全局功耗。然而,这种解决方案是幼稚的,因为电力使用不是传输速率的线性函数,所以用这种方式来分解它没有什么意义。当我们考虑到Wi-Fi接口的功耗不是一个一维函数时,这个解决方案似乎更加可疑。
相反,我们需要一个独立于每个组件的强大功能工作的解决方案,并且对应用程序开发人员(PowerTutor的主要目标用户)来说是直观的。对于每个组件,我们计算功耗,就像每个特定应用程序单独运行一样,意味着在情况1中,每个应用程序将为低功率Wi-Fi状态充电,而在情况2中,每个应用将为高功率状态充电。这就失去了应用程序功耗之和等于全局功耗的良好特性(正如这两个案例所说明的那样,它既不是低估值,也不是高估值)。然而,通过这个定义,我们可以独立于其他正在运行的应用程序来理解应用程序的功耗,使得PowerTutor的用户可以观察到类似的应用程序级功率特性,而不考虑资源共享:对于专注于优化特定应用程序的工程师来说是一个有用的特性。请注意,PowerTutor还报告了准确的系统级功耗。
PowerTutor界面。(a) 应用程序视图。(b) 图表视图。(c) 饼状视图。图中无意但不适当地使用智能手机硬件组件。
此解决方案存在局限性。第一,如果应用程序正在争夺资源,那么很难预测它们单独执行时的行为。第二,在某些情况下,我们看到一个应用程序调用另一个来执行某项任务。在这种情况下,如何分配功耗尚不清楚。在真实的Android系统中,媒体服务器进程经常发生这种行为。第三,使用巧妙技术的应用程序,如与其他应用程序同时进行传输,不会在该方案中获得收益。然而,对于第一个案例,我们无能为力。解决其他两个问题需要对所涉及的应用程序的语义有一个高层次的理解,这目前超出了我们工具的范围。
下表显示了ADP1和ADP2手机的内部和内部电源型号变化。类型内变化是由同一类型手机样本的平均值归一化的标准差,类型间差异是两种类型手机的样本均值之间的差异。注意,表中的功率模型参数也可以被视为特定工作负载的功率测量,即功率模型参数的变化与预测误差线性相关。例如,对于使用音频设备的应用程序,我们预计使用为ADP1导出的功率模型预测另一个ADP1的音频设备功耗时,预测误差小于4%。这些数据为以下结论提供了一些支持。
Android系统中的图形架构如下:
下图显示了DRS(Dynamic Resolution Scaling,动态分辨率缩放)系统的架构。为了实现分辨率缩放,在现有Android系统中添加了两个新层:
- 第一层是DRS上层,位于应用层和OpenGL ES/EGL层之间,拦截必要的OpenGLES函数调用和EGL函数调用,以确保以适当的显示分辨率完成图形渲染。它将缩放因子应用于必要的OpenGL ES/EGL函数调用的参数,以将默认显示分辨率转换为目标分辨率。
- 第二层是DRS下层,位于SurfaceFlinger层和Hardware Composer之间。该层拦截传递给硬件合成器的函数调用,以确保以正确的显示分辨率完成合成。第二层的作用是在DRS上层降低分辨率后提高分辨率,以便渲染的内容能够以本地显示分辨率正确显示在屏幕上。
这两个DRS层彼此同步,以确保它们对BufferQueue中的相同图形缓冲区使用相同的目标显示分辨率,此举很有必要,因为如果用户将目标显示分辨率更改为新值,DRS上层将开始使用新的缩放因子将图形缓冲区生成到BufferQueue中。DRS下层需要确保旧的缩放因子用于先前生成的图形缓冲区,并且新的缩放因子在合成期间仅应用于新生成的图形缓冲器。
不同缩放因子下每帧游戏和基准的标准化能量如下表:
从覆盖率测试中的15个应用程序,包括14个游戏和一个基准(上表中列出的名称)来评估在不同的显示分辨率下可以节省多少电量。在S5手机上运行测试用例,并使用季风功率监视器测量系统功率,将手机切换到飞行模式,禁用不必要的硬件组件,如GPS和摄像头,并将背光亮度设置为50%。将GPU频率锁定为500MHz,以避免GPU的DVFS推断。在每次测试前都会对手机进行冷却,以确保GPU能够在500MHz下工作至少60秒。将每个测试重复三次,并报告平均结果。
采用每帧总系统能量(EPF)作为衡量标准来评估原型系统的节能。为了方便地比较不同的结果,将结果标准化为原生显示分辨率的情况。上表显示了不同比例因子的归一化EPF。缩放因子被归一化为原生显示分辨率,即对于全分辨率,缩放因子为1.0。当显示分辨率降低一半(即缩放因子为0.5,将显示分辨率从2560x1440像素降低到1280x720像素)时,对于16个测试用例,平均而言,可以将EPF降低30.1%,范围从15.7%到60.5%。对于这14款游戏,无论缩放因子是什么值,它们总是以固定的帧速率运行。因此,在实践中可以实现相同的功耗节省量(如果仅计算这14款比赛,则为24.9%)。对于两种GFXBench情况,由于基准测试总是试图用尽所有GPU处理能力,因此其功耗在所有缩放因子中几乎保持不变。然而,分辨率会极大地影响帧速率。对于较小的缩放因子,它们可以以较高的帧速率运行,从而提供更好的用户体验。
19.10.3 电源优化
在开始应用程序开发之前,分析并定义应用程序的需求、范围和功能,以确保高效的功能和流畅的用户体验。为单一目的设计应用程序,并分析它如何最好地为用户服务。
以下指南帮助您设计和开发适用于具有不同特性(如屏幕大小和输入法支持)的移动设备的应用程序:
- 了解目标用户。了解谁将使用该应用程序,他们将使用它做什么,以及拥有哪些移动设备,然后设计应用程序以适应特定的使用环境。
- 小屏幕设计。移动设备的屏幕尺寸明显小于桌面设备的屏幕大小。仔细考量在应用程序UI上显示的最相关的内容是什么,因为在桌面应用程序中尝试将尽可能多的内容放入屏幕可能是不合理的。
- 多种屏幕尺寸的设计。将每个控件的位置和大小与显示器的尺寸相关联,使得同一组信息能够以所有分辨率显示在屏幕上,更高分辨率的设备只显示更精细的图形。
- 更改屏幕方向的设计。某些设备支持屏幕旋转,在这些设备上,应用程序可以纵向或横向显示,考虑方向并在屏幕旋转时动态调整显示。
- 设计在应用程序中移动的直观方式。移动设备缺少鼠标和全尺寸键盘,因此用户必须使用触摸屏或五向导航板在应用程序中移动,此外,许多用户用一只手控制设备。要创建优化的用户体验,允许用户一键访问信息,不要让它们滚动和键入。
- 有限输入法设计。应用程序从用户那里收集有关手头任务的信息。除了触摸屏输入,一些设备还包含物理键,如五向导航板、键盘和键盘。用户通过使用屏幕控件(如列表、复选框、单选按钮和文本字段)输入信息。
- 缩短响应时间。延迟可能会导致用户交互延迟。如果用户认为某个应用程序速度慢,他们很可能会感到沮丧并停止使用它。
- 节省电池时间。移动设备不总是连接到电源,而是依靠电池供电。优化功耗以将总功耗保持在可接受的水平,并防止用户耗尽电池时间。
- 考虑网络问题。如果用户没有固定费率的数据计划或WLAN支持,移动网络连接会让他们花钱。此外,当用户带着设备四处移动时,可用于连接的网络会不断变化。
- 记住设备的处理限制。设备上可用的内存有限,应谨慎使用。尽管所有移动设备都具有通用功能,但就可用资源和额外功能而言,每个设备都是独立的,因此必须考虑所有目标设备的约束。
- 最大限度地提高应用程序的效率和电池寿命。
- 优化切换器效率(针对大部分时间使用处理器的地方)。
- 使用PFM、PWM-PS提高低功率条件下的效率。
- 最小化物料清单(BOM)成本和面积。
- 电池技术(1、2或3个锂离子电池)。
- 保持功耗在应用范围内。
- 软件驱动程序支持。
- 灵活的加电顺序/默认电压,支持多处理器和外围设备。
- PMIC内部或外部音频。内部优势:降低成本,节省电路板空间,外部优势:噪音更小、更灵活。
- 使用动态分辨率。详见上节。
高通采用了整体系统方法,通过定制关键技术块和整个片上系统(SoC)来实现节能。
该系统方法涉及四个关键级别的功率和热量优化:
-
专用处理引擎。定制设计专用处理引擎和其他关键组件,如电源管理集成电路(PMIC)、射频(RF)芯片等。
-
微架构。
aSMP和其它典型的SMP实现。
-
电路设计。
-
晶体管级别设计。
骁龙SoC内的处理引擎。
-
-
智能集成。巧妙地集成了技术块并设计了系统架构。
- 系统结构/互连。
- 缓存和内存设计。
- 软件与硬件加速。
-
优化系统软件。将软件与硬件紧密结合。
- 软件工具和API。
- 软件、OS和编译器优化。
- 电源和热量算法。
-
设备级优化。仔细考量移动设备上的所有其他组件,并优化了整个解决方案的操作。
- 电源和热量模型。
- 设备组件优化。
- OEM最佳实践。
为了解决在具有功率和热量限制的设备中提供更高性能的日益增加的挑战,以移动为中心的设计方法至关重要。高通采用整体系统方法进行电源和热管理,其移动SoC通过设计专门的处理引擎,巧妙地集成它们,并优化系统软件和整个设备,实现了功率和热效率的最佳平衡,使移动设备能够提供最佳的用户体验。
关于电源技术的更多详情可参阅:
- Power, Performance Modeling and Optimization for Mobile System and Applications
- 18.11.7 电源管理
19.11 UE硬件
本章节将基于UE 5.1的源码解析涉及的硬件接口和逻辑。
19.11.1 CPU
下面的接口可以计算CPU的性能等级等参数:
// GenericPlatformSurvey.h
struct FSynthBenchmarkResults
{
FSynthBenchmarkStat CPUStats[2];
// 计算CPU性能等级,100表明是平局等级的CPU, 小于100更慢, 大于100更快。
float ComputeCPUPerfIndex(TArray<float>* OutIndividualResults = nullptr) const;
};
下面的接口可以追踪CPU的性能,包含追踪数据、利用率、分析器等:
// CpuProfilerTrace.h
struct FCpuProfilerTrace
{
static uint32 OutputEventType(const ANSICHAR* Name, const ANSICHAR* File = nullptr, uint32 Line = 0);
static uint32 OutputEventType(const TCHAR* Name, const ANSICHAR* File = nullptr, uint32 Line = 0);
static void OutputBeginEvent(uint32 SpecId);
static void OutputBeginDynamicEvent(const ANSICHAR* Name, const ANSICHAR* File = nullptr, uint32 Line = 0);
static void OutputBeginDynamicEvent(const TCHAR* Name, const ANSICHAR* File = nullptr, uint32 Line = 0);
static void OutputBeginDynamicEvent(const FName& Name, const ANSICHAR* File = nullptr, uint32 Line = 0);
static void OutputEndEvent();
static void OutputResumeEvent(uint64 SpecId, uint32& TimerScopeDepth);
static void OutputSuspendEvent();
class FEventScope
{
(...)
};
struct FDynamicEventScope
{
(...)
};
(...)
};
// CpuProfilerTraceAnalysis.h
class FCpuProfilerAnalyzer : public UE::Trace::IAnalyzer
{
public:
virtual void OnAnalysisBegin(const FOnAnalysisContext& Context) override;
virtual void OnAnalysisEnd(/*const FOnAnalysisEndContext& Context*/) override;
virtual bool OnEvent(uint16 RouteId, EStyle Style, const FOnEventContext& Context) override;
private:
IAnalysisSession& Session;
IEditableTimingProfilerProvider& EditableTimingProfilerProvider;
IEditableThreadProvider& EditableThreadProvider;
TMap<uint32, FThreadState*> ThreadStatesMap;
TMap<uint32, uint32> SpecIdToTimerIdMap;
TMap<const TCHAR*, uint32> ScopeNameToTimerIdMap;
uint32 CoroutineTimerId = ~0;
uint32 CoroutineUnknownTimerId = ~0;
uint64 TotalEventSize = 0;
uint64 TotalScopeCount = 0;
double BytesPerScope = 0.0;
(...)
};
以下接口包含CPU的时钟、频率、亲缘性等信息和接口:
// GenericPlatformTime.h
// 包含CPU利用率数据
struct FCPUTime
{
float CPUTimePct; // 上一个间隔的CPU利用率百分比。
float CPUTimePctRelative; // 上一个间隔相对于一个核心的CPU利用率百分比,因此如果CPUTimePct为8.0%,而设备有6个核心,则该值将为48.0%。
};
// 时间
struct FGenericPlatformTime
{
// 时间、时钟、频率等接口
static TCHAR* StrDate( TCHAR* Dest, SIZE_T DestSize );
static TCHAR* StrTime( TCHAR* Dest, SIZE_T DestSize );
static const TCHAR* StrTimestamp();
static FString PrettyTime( double Seconds );
static bool UpdateCPUTime( float DeltaTime );
static bool UpdateThreadCPUTime(float = 0.0);
static void AutoUpdateGameThreadCPUTime(double UpdateInterval);
static FCPUTime GetCPUTime();
static FCPUTime GetThreadCPUTime();
static double GetLastIntervalCPUTimeInSeconds();
static double GetLastIntervalThreadCPUTimeInSeconds();
static double GetSecondsPerCycle();
static float ToMilliseconds( const uint32 Cycles );
static float ToSeconds( const uint32 Cycles );
static double GetSecondsPerCycle64();
static double ToMilliseconds64(const uint64 Cycles);
static double ToSeconds64(const uint64 Cycles);
(...)
protected:
static double SecondsPerCycle;
static double SecondsPerCycle64;
static double LastIntervalCPUTimeInSeconds;
};
// PlatformAffinity.h
struct FThreadAffinity
{
uint64 ThreadAffinityMask = FPlatformAffinity::GetNoAffinityMask();
uint16 ProcessorGroup = 0;
};
19.11.2 内存
以下代码包含内存的硬件信息、分配、缓存、池化等接口:
// GenericPlatformMemory.h
struct FGenericPlatformMemory
{
static bool bIsOOM; // 是否内存不足
static uint64 OOMAllocationSize; // 设置为触发内存不足的分配大小,否则为零.
static uint32 OOMAllocationAlignment; // 设置为触发内存不足的分配对齐,否则为零。
static void* BackupOOMMemoryPool; // 内存不足时要删除的预分配缓冲区。用于OOM处理和崩溃报告。
static uint32 BackupOOMMemoryPoolSize; // BackupOOMMemoryPool的大小(字节)。
// 可用于内存统计的各种内存区域。枚举的确切含义相对依赖于平台,尽管一般的(物理、GPU)很简单。一个平台可以添加更多的内存,并且不会影响其他平台,除了StatManager跟踪每个区域的最大可用内存(使用数组FPlatformMemory::MCR_max big)所需的少量内存之外.
enum EMemoryCounterRegion
{
MCR_Invalid, // not memory
MCR_Physical, // main system memory
MCR_GPU, // memory directly a GPU (graphics card, etc)
MCR_GPUSystem, // system memory directly accessible by a GPU
MCR_TexturePool, // presized texture pools
MCR_StreamingPool, // amount of texture pool available for streaming.
MCR_UsedStreamingPool, // amount of texture pool used for streaming.
MCR_GPUDefragPool, // presized pool of memory that can be defragmented.
MCR_PhysicalLLM, // total physical memory including CPU and GPU
MCR_MAX
};
// 使用的分配器.
enum EMemoryAllocatorToUse
{
Ansi, // Default C allocator
Stomp, // Allocator to check for memory stomping
TBB, // Thread Building Blocks malloc
Jemalloc, // Linux/FreeBSD malloc
Binned, // Older binned malloc
Binned2, // Newer binned malloc
Binned3, // Newer VM-based binned malloc, 64 bit only
Platform, // Custom platform specific allocator
Mimalloc, // mimalloc
};
static EMemoryAllocatorToUse AllocatorToUse;
enum ESharedMemoryAccess
{
Read = (1 << 1),
Write = (1 << 2)
};
// 共享内存区域的通用表示
struct FSharedMemoryRegion
{
TCHAR Name[MaxSharedMemoryName];
uint32 AccessMode;
void * Address;
SIZE_T Size;
};
// 内存操作.
static void Init();
static void OnOutOfMemory(uint64 Size, uint32 Alignment);
static void SetupMemoryPools();
static uint32 GetBackMemoryPoolSize()
static FMalloc* BaseAllocator();
static FPlatformMemoryStats GetStats();
static uint64 GetMemoryUsedFast();
static void GetStatsForMallocProfiler( FGenericMemoryStats& out_Stats );
static const FPlatformMemoryConstants& GetConstants();
static uint32 GetPhysicalGBRam();
static bool PageProtect(void* const Ptr, const SIZE_T Size, const bool bCanRead, const bool bCanWrite);
// 分配.
static void* BinnedAllocFromOS( SIZE_T Size );
static void BinnedFreeToOS( void* Ptr, SIZE_T Size );
static void NanoMallocInit();
static bool PtrIsOSMalloc( void* Ptr);
static bool IsNanoMallocAvailable();
static bool PtrIsFromNanoMalloc( void* Ptr);
// 虚拟内存块及操作.
class FBasicVirtualMemoryBlock
{
protected:
void *Ptr;
uint32 VMSizeDivVirtualSizeAlignment;
public:
FBasicVirtualMemoryBlock(const FBasicVirtualMemoryBlock& Other) = default;
FBasicVirtualMemoryBlock& operator=(const FBasicVirtualMemoryBlock& Other) = default;
FORCEINLINE uint32 GetActualSizeInPages() const;
FORCEINLINE void* GetVirtualPointer() const;
void Commit(size_t InOffset, size_t InSize);
void Decommit(size_t InOffset, size_t InSize);
void FreeVirtual();
void CommitByPtr(void *InPtr, size_t InSize);
void DecommitByPtr(void *InPtr, size_t InSize);
void Commit();
void Decommit();
size_t GetActualSize() const;
static FPlatformVirtualMemoryBlock AllocateVirtual(size_t Size, ...);
static size_t GetCommitAlignment();
static size_t GetVirtualSizeAlignment();
};
// 数据和调试
static bool BinnedPlatformHasMemoryPoolForThisSize(SIZE_T Size);
static void DumpStats( FOutputDevice& Ar );
static void DumpPlatformAndAllocatorStats( FOutputDevice& Ar );
static EPlatformMemorySizeBucket GetMemorySizeBucket();
// 内存数据操作.
static void* Memmove( void* Dest, const void* Src, SIZE_T Count );
static int32 Memcmp( const void* Buf1, const void* Buf2, SIZE_T Count );
static void* Memset(void* Dest, uint8 Char, SIZE_T Count);
static void* Memzero(void* Dest, SIZE_T Count);
static void* Memcpy(void* Dest, const void* Src, SIZE_T Count);
static void* BigBlockMemcpy(void* Dest, const void* Src, SIZE_T Count);
static void* StreamingMemcpy(void* Dest, const void* Src, SIZE_T Count);
static void* ParallelMemcpy(void* Dest, const void* Src, SIZE_T Count, EMemcpyCachePolicy Policy = EMemcpyCachePolicy::StoreCached);
(...)
};
// 结构用于保存所有平台的通用内存常数。这些值不会在可执行文件的整个生命周期内发生变化。
struct FGenericPlatformMemoryConstants
{
// 实际物理内存量,以字节为单位(对于运行32位代码的64位设备,需要处理>4GB)。
uint64 TotalPhysical;
// 虚拟内存量,以字节为单位
uint64 TotalVirtual;
// 物理页面的大小,以字节为单位,也是物理RAM的PageProtection()、提交和属性(例如访问能力)的粒度。
SIZE_T PageSize;
// 如果内存以大于PageSize的块分配,则某些平台具有优势(例如VirtualAlloc()目前的粒度似乎为64KB),该值是系统将在后台使用的最小分配大小。
SIZE_T OsAllocationGranularity;
// Binned2 malloc术语中“页面”的大小,以字节为单位,至少为64KB。BinnedMloc希望从BinnedAllocFromOS()返回的内存与BinnedPageSize边界对齐。
SIZE_T BinnedPageSize;
// BinnedMalloc术语中的“分配粒度”,即BinnedMlloc将以该值的增量分配内存。如果为0,Binned将对此值使用BinnedPageSize.
SIZE_T BinnedAllocationGranularity;
// AddressLimit-第二个参数是BinnedAllocFromOS()预期返回的地址范围的估计值。Binned Malloc将调整其内部结构,以查找此范围的内存分配O(1)。超出这个范围是可以的,查找会稍微慢一点
uint64 AddressLimit;
// 近似物理RAM(GB),除PC外的所有设备上都有1。用于“course tuning”,如FPlatformMisc::NumberOfCores()。
uint32 TotalPhysicalGB;
};
// 用于保存所有平台的通用内存统计信息,可能会在可执行文件的整个生命周期内发生变化。
struct FGenericPlatformMemoryStats : public FPlatformMemoryConstants
{
// 当前可用的物理内存量,以字节为单位。
uint64 AvailablePhysical;
// 当前可用的虚拟内存量(字节)。
uint64 AvailableVirtual;
// 进程使用的物理内存量,以字节为单位。
uint64 UsedPhysical;
// 进程使用的物理内存的峰值量,以字节为单位
uint64 PeakUsedPhysical;
// 进程使用的虚拟内存总量。
uint64 UsedVirtual;
// 进程使用的虚拟内存的峰值量。
uint64 PeakUsedVirtual;
// 内存压力状态,适用于可用内存估计可能不考虑关闭非活动进程或诉诸交换可回收内存的平台。
enum class EMemoryPressureStatus : uint8
{
Unknown,
Nominal,
Critical, // OOM(Out Of Memory)条件的高风险
};
EMemoryPressureStatus GetMemoryPressureStatus();
struct FPlatformSpecificStat
{
const TCHAR* Name;
uint64 Value;
};
TArray<FPlatformSpecificStat> GetPlatformSpecificStats() const;
uint64 GetAvailablePhysical(bool bExcludeExtraDevMemory) const;
// 由FCsvProfiler::EndFrame调用以设置特定于平台的CSV统计信息。
void SetEndFrameCsvStats() const {}
};
以下接口指示了D3D12的某些资源是CPU或GPU的可读可写性:
// D3D12Util.h
inline bool IsCPUWritable(D3D12_HEAP_TYPE HeapType, const D3D12_HEAP_PROPERTIES *pCustomHeapProperties = nullptr);
inline bool IsGPUOnly(D3D12_HEAP_TYPE HeapType, const D3D12_HEAP_PROPERTIES *pCustomHeapProperties = nullptr);
inline bool IsCPUAccessible(D3D12_HEAP_TYPE HeapType, const D3D12_HEAP_PROPERTIES* pCustomHeapProperties = nullptr);
以下代码是内存追踪相关的类型和接口:
// MemoryTrace.h
enum EMemoryTraceRootHeap : uint8
{
SystemMemory, // RAM
VideoMemory, // VRAM
EndHardcoded = VideoMemory,
EndReserved = 15
};
// 追踪堆标记。
enum class EMemoryTraceHeapFlags : uint16
{
None = 0,
Root = 1 << 0,
NeverFrees = 1 << 1, // The heap doesn't free (e.g. linear allocator)
};
ENUM_CLASS_FLAGS(EMemoryTraceHeapFlags);
enum class EMemoryTraceHeapAllocationFlags : uint8
{
None = 0,
Heap = 1 << 0, // Is a heap, can be used to unmark alloc as heap.
};
ENUM_CLASS_FLAGS(EMemoryTraceHeapAllocationFlags);
class FMalloc* MemoryTrace_Create(class FMalloc* InMalloc);
void MemoryTrace_Initialize();
HeapId MemoryTrace_HeapSpec(HeapId ParentId, const TCHAR* Name, EMemoryTraceHeapFlags Flags = EMemoryTraceHeapFlags::None);
HeapId MemoryTrace_RootHeapSpec(const TCHAR* Name, EMemoryTraceHeapFlags Flags = EMemoryTraceHeapFlags::None);
void MemoryTrace_MarkAllocAsHeap(uint64 Address, HeapId Heap, ...);
void MemoryTrace_UnmarkAllocAsHeap(uint64 Address, HeapId Heap);
void MemoryTrace_Alloc(uint64 Address, uint64 Size, uint32 Alignment, HeapId RootHeap = EMemoryTraceRootHeap::SystemMemory);
void MemoryTrace_Free(uint64 Address, HeapId RootHeap = EMemoryTraceRootHeap::SystemMemory);
void MemoryTrace_ReallocFree(uint64 Address, HeapId RootHeap = EMemoryTraceRootHeap::SystemMemory);
void MemoryTrace_ReallocAlloc(uint64 Address, uint64 NewSize, uint32 Alignment, HeapId RootHeap = EMemoryTraceRootHeap::SystemMemory);
(...)
以下代码涉及了GPU资源数组的操作:
// ResourceArray.h
// 资源数组的独立于元素类型的接口。
class FResourceArrayInterface
{
public:
virtual const void* GetResourceData() const = 0;
virtual uint32 GetResourceDataSize() const = 0;
virtual void Discard() = 0;
virtual bool IsStatic() const = 0;
virtual bool GetAllowCPUAccess() const = 0;
virtual void SetAllowCPUAccess( bool bInNeedsCPUAccess ) = 0;
};
// 允许直接为批量资源类型分配GPU内存。
class FResourceBulkDataInterface
{
public:
virtual const void* GetResourceBulkData() const = 0;
virtual uint32 GetResourceBulkDataSize() const = 0;
virtual void Discard() = 0;
enum class EBulkDataType
{
Default,
MediaTexture,
VREyeBuffer,
};
virtual EBulkDataType GetResourceType() const;
};
// 允许直接为纹理资源分配GPU内存。
class FTexture2DResourceMem : public FResourceBulkDataInterface
{
public:
virtual void* GetMipData(int32 MipIdx) = 0;
virtual int32 GetNumMips() = 0;
virtual int32 GetSizeX() = 0;
virtual int32 GetSizeY() = 0;
virtual bool IsValid() = 0;
virtual bool HasAsyncAllocationCompleted() const = 0;
virtual void FinishAsyncAllocation() = 0;
virtual void CancelAsyncAllocation() = 0;
};
以下是操作系统页缓存分配器:
// CachedOSPageAllocator.h
struct FCachedOSPageAllocator
{
protected:
struct FFreePageBlock
{
void* Ptr;
SIZE_T ByteSize;
};
void* AllocateImpl(SIZE_T Size, uint32 CachedByteLimit, FFreePageBlock* First, FFreePageBlock* Last, ...);
void FreeImpl(void* Ptr, SIZE_T Size, uint32 NumCacheBlocks, uint32 CachedByteLimit, FFreePageBlock* First, ...);
void FreeAllImpl(FFreePageBlock* First, uint32& FreedPageBlocksNum, SIZE_T& CachedTotal, FCriticalSection* Mutex);
};
template <uint32 NumCacheBlocks, uint32 CachedByteLimit>
struct TCachedOSPageAllocator : private FCachedOSPageAllocator
{
void* Allocate(SIZE_T Size, uint32 AllocationHint = 0, FCriticalSection* Mutex = nullptr);
void Free(void* Ptr, SIZE_T Size, FCriticalSection* Mutex = nullptr, bool ThreadIsTimeCritical = false);
void FreeAll(FCriticalSection* Mutex = nullptr);
void UpdateStats();
uint64 GetCachedFreeTotal();
private:
FFreePageBlock FreedPageBlocks[NumCacheBlocks*2];
SIZE_T CachedTotal;
uint32 FreedPageBlocksNum;
};
// CachedOSVeryLargePageAllocator.h
// 超大页面的缓存分配器。
class FCachedOSVeryLargePageAllocator
{
// 将地址空间设置为所需的两倍,并将第一个用于小池分配,第二个用于仍为==SizeOfSubPage的其他分配
#if UE_VERYLARGEPAGEALLOCATOR_TAKEONALL64KBALLOCATIONS
static constexpr uint64 AddressSpaceToReserve = ((1024LL * 1024LL * 1024LL) * UE_VERYLARGEPAGEALLOCATOR_RESERVED_SIZE_IN_GB * 2LL);
static constexpr uint64 AddressSpaceToReserveForSmallPool = AddressSpaceToReserve/2;
#else
static constexpr uint64 AddressSpaceToReserve = ((1024 * 1024 * 1024LL) * UE_VERYLARGEPAGEALLOCATOR_RESERVED_SIZE_IN_GB);
static constexpr uint64 AddressSpaceToReserveForSmallPool = AddressSpaceToReserve;
#endif
static constexpr uint64 SizeOfLargePage = (UE_VERYLARGEPAGEALLOCATOR_PAGESIZE_KB * 1024);
static constexpr uint64 SizeOfSubPage = (1024 * 64);
static constexpr uint64 NumberOfLargePages = (AddressSpaceToReserve / SizeOfLargePage);
static constexpr uint64 NumberOfSubPagesPerLargePage = (SizeOfLargePage / SizeOfSubPage);
public:
void* Allocate(SIZE_T Size, uint32 AllocationHint = 0, FCriticalSection* Mutex = nullptr);
void Free(void* Ptr, SIZE_T Size, FCriticalSection* Mutex = nullptr, bool ThreadIsTimeCritical = false);
void FreeAll(FCriticalSection* Mutex = nullptr);
void UpdateStats();
uint64 GetCachedFreeTotal();
bool IsPartOf(const void* Ptr);
private:
(...)
FLargePage* FreeLargePagesHead[FMemory::AllocationHints::Max]; // no backing store
FLargePage* UsedLargePagesHead[FMemory::AllocationHints::Max]; // has backing store and is full
FLargePage* UsedLargePagesWithSpaceHead[FMemory::AllocationHints::Max]; // has backing store and still has room
FLargePage* EmptyButAvailableLargePagesHead[FMemory::AllocationHints::Max]; // has backing store and is empty
FLargePage LargePagesArray[NumberOfLargePages];
TCachedOSPageAllocator<CACHEDOSVERYLARGEPAGEALLOCATOR_MAX_CACHED_OS_FREES, CACHEDOSVERYLARGEPAGEALLOCATOR_BYTE_LIMIT> CachedOSPageAllocator;
};
CORE_API extern bool GEnableVeryLargePageAllocator;
// PooledVirtualMemoryAllocator.h
// 此Class将从FMallocBinned2进行的OS分配汇集在一起。
struct FPooledVirtualMemoryAllocator
{
void* Allocate(SIZE_T Size, uint32 AllocationHint = 0, FCriticalSection* Mutex = nullptr);
void Free(void* Ptr, SIZE_T Size, FCriticalSection* Mutex = nullptr, bool ThreadIsTimeCritical = false);
void FreeAll(FCriticalSection* Mutex = nullptr);
// 描述特定大小池的结构
struct FPoolDescriptorBase
{
FPoolDescriptorBase* Next;
SIZE_T VMSizeDivVirtualSizeAlignment;
};
uint64 GetCachedFreeTotal();
void UpdateStats();
private:
enum Limits
{
NumAllocationSizeClasses = 64,
MaxAllocationSizeToPool = NumAllocationSizeClasses * 65536,
MaxOSAllocCacheSize = 64 * 1024 * 1024,
MaxOSAllocsCached = 64
};
int32 GetAllocationSizeClass(SIZE_T Size);
SIZE_T CalculateAllocationSizeFromClass(int32 Class);
int32 NextPoolSize[Limits::NumAllocationSizeClasses];
FPoolDescriptorBase* ClassesListHeads[Limits::NumAllocationSizeClasses];
FCriticalSection ClassesLocks[Limits::NumAllocationSizeClasses];
void DecideOnTheNextPoolSize(int32 SizeClass, bool bGrowing);
FPoolDescriptorBase* CreatePool(SIZE_T AllocationSize, int32 NumPooledAllocations);
void DestroyPool(FPoolDescriptorBase* Pool);
FCriticalSection OsAllocatorCacheLock;
TCachedOSPageAllocator<MaxOSAllocsCached, MaxOSAllocCacheSize> OsAllocatorCache;
};
以下是虚拟内存分配器:
// VirtualAllocator.h
class FVirtualAllocator
{
struct FFreeLink
{
void *Ptr = nullptr;
FFreeLink* Next = nullptr;
};
struct FPerBlockSize
{
int64 AllocBlocksSize = 0;
int64 FreeBlocksSize = 0;
FFreeLink* FirstFree = nullptr;
};
FCriticalSection CriticalSection;
uint8* LowAddress;
uint8* HighAddress;
size_t TotalSize;
size_t PageSize;
size_t MaximumAlignment;
uint8* NextAlloc;
FFreeLink* RecycledLinks;
int64 LinkSize;
bool bBacksMalloc;
FPerBlockSize Blocks[64];
void FreeVirtualByBlock(void* Ptr, FPerBlockSize& Block, size_t AlignedSize);
protected:
size_t SpaceConsumed;
virtual uint8* AllocNewVM(size_t AlignedSize)
{
uint8* Result = NextAlloc;
check(IsAligned(Result, MaximumAlignment) && IsAligned(AlignedSize, MaximumAlignment));
NextAlloc = Result + AlignedSize;
SpaceConsumed = NextAlloc - LowAddress;
return Result;
}
public:
uint32 GetPagesForSizeAndAlignment(size_t Size, size_t Alignment = 1) const;
void* AllocateVirtualPages(uint32 NumPages, size_t AlignmentForCheck = 1);
void FreeVirtual(void* Ptr, uint32 NumPages);
struct FVirtualAllocatorStatsPerBlockSize
{
size_t AllocBlocksSize;
size_t FreeBlocksSize;
};
struct FVirtualAllocatorStats
{
size_t PageSize;
size_t MaximumAlignment;
size_t VMSpaceTotal;
size_t VMSpaceConsumed;
size_t VMSpaceConsumedPeak;
size_t FreeListLinks;
FVirtualAllocatorStatsPerBlockSize BlockStats[64];
};
void GetStats(FVirtualAllocatorStats& OutStats);
};
19.11.3 GPU
下面的接口可以计算GPU的性能等级等参数:
// GenericPlatformSurvey.h
struct FSynthBenchmarkResults
{
FSynthBenchmarkStat GPUStats[7];
// 计算GPU性能等级,100表明是平局等级的CPU, 小于100更慢, 大于100更快。
float ComputeGPUPerfIndex(TArray<float>* OutIndividualResults = nullptr) const;
// 以秒为单位返回,用于检查基准测试是否耗时过长(硬件速度非常慢,不要使用大型WorkScale进行测试).
float ComputeTotalGPUTime() const;
};
// GPU适配器
struct FGPUAdpater
{
static const uint32 MaxStringLength = 260;
// 名称
TCHAR AdapterName[MaxStringLength];
// 内部驱动版本
TCHAR AdapterInternalDriverVersion[MaxStringLength];
// 用户驱动版本
TCHAR AdapterUserDriverVersion[MaxStringLength];
// 额外的数据
TCHAR AdapterDriverDate[MaxStringLength];
// 适配器专用的内存
TCHAR AdapterDedicatedMemoryMB[MaxStringLength];
};
下面代码涉及了GPU驱动相关的信息和操作:
// GenericPlatformDriver.h
// GPU驱动信息。
struct FGPUDriverInfo
{
// DirectX VendorId,0(如果未设置),请使用以下函数设置/获取
uint32 VendorId;
// e.g. "NVIDIA GeForce GTX 680" or "AMD Radeon R9 200 / HD 7900 Series"
FString DeviceDescription;
// e.g. "NVIDIA" or "Advanced Micro Devices, Inc."
FString ProviderName;
// e.g. "15.200.1062.1004"(AMD)
// e.g. "9.18.13.4788"(NVIDIA)
// 第一个数字是Windows版本(例如7:Vista、6:XP、4:Me、9:Win8(1)、10:Win7),最后5个数字编码了UserDriver版本,也称为技术版本号(https://wiki.mozilla.org/Blocklisting/Blocked_Graphics_Drivers)如果驱动程序检测失败,则TEXT("Unknown")
FString InternalDriverVersion;
// e.g. "Catalyst 15.7.1"(AMD) or "Crimson 15.7.1"(AMD) or "347.88"(NVIDIA)
// 也称为商业版本号
FString UserDriverVersion;
// e.g. 3-13-2015
FString DriverDate;
// e.g. D3D11, D3D12
FString RHIName;
bool IsValid() const;
// get VendorId
bool IsAMD() const { return VendorId == 0x1002; }
// get VendorId
bool IsIntel() const { return VendorId == 0x8086; }
// get VendorId
bool IsNVIDIA() const { return VendorId == 0x10DE; }
bool IsSameDriverVersionGeneration(const TCHAR* InOpWithMultiInt) const;
static FString TrimNVIDIAInternalVersion(const FString& InternalVersion);
FString GetUnifiedDriverVersion() const;
};
// Hardware.ini文件中的一个条目
struct FDriverDenyListEntry
{
// optional, e.g. "<=223.112.21.1", might includes comparison operators, later even things multiple ">12.22 <=12.44"
FString DriverVersionString;
// optional, e.g. "<=MM-DD-YYYY"
FString DriverDateString;
// optional, e.g. "D3D11", "D3D12"
FString RHIName;
// required
FString Reason;
void LoadFromINIString(const TCHAR* In);
bool IsValid() const;
bool IsLatestDenied() const;
};
// GPU硬件信息
struct FGPUHardware
{
const FGPUDriverInfo DriverInfo;
FString GetSuggestedDriverVersion(const FString& InRHIName) const;
FDriverDenyListEntry FindDriverDenyListEntry() const;
bool IsLatestDenied() const;
FString GetVendorSectionName() const;
};
下面代码是GPU的装箱分配器:
// MallocBinnedGPU.h
class FMallocBinnedGPU final : public FMalloc
{
struct FGPUMemoryBlockProxy
{
uint8 MemoryModifiedByCPU[32 - sizeof(void*)]; // might be modified for free list links, etc
void *GPUMemory; // pointer to the actual GPU memory, which we cannot modify with the CPU
};
struct FFreeBlock
{
uint16 BlockSizeShifted; // Size of the blocks that this list points to >> ArenaParams.MinimumAlignmentShift
uint8 PoolIndex; // Index of this pool
uint8 Canary; // Constant value of 0xe3
uint32 NumFreeBlocks; // Number of consecutive free blocks here, at least 1.
FFreeBlock* NextFreeBlock; // Next free block or nullptr
};
struct FPoolTable
{
uint32 BlockSize;
uint16 BlocksPerBlockOfBlocks;
uint8 PagesPlatformForBlockOfBlocks;
FBitTree BlockOfBlockAllocationBits; // one bits in here mean the virtual memory is committed
FBitTree BlockOfBlockIsExhausted; // one bit in here means the pool is completely full
uint32 NumEverUsedBlockOfBlocks;
FPoolInfoSmall** PoolInfos;
uint64 UnusedAreaOffsetLow;
};
struct FPtrToPoolMapping
{
private:
/** Shift to apply to a pointer to get the reference from the indirect tables */
uint64 PtrToPoolPageBitShift;
/** Shift required to get required hash table key. */
uint64 HashKeyShift;
/** Used to mask off the bits that have been used to lookup the indirect table */
uint64 PoolMask;
// PageSize dependent constants
uint64 MaxHashBuckets;
};
struct FBundleNode
{
FBundleNode* NextNodeInCurrentBundle;
union
{
FBundleNode* NextBundle;
int32 Count;
};
};
struct FBundle
{
FBundleNode* Head;
uint32 Count;
};
// 空闲的块列表
struct FFreeBlockList
{
bool PushToFront(FMallocBinnedGPU& Allocator, void* InPtr, uint32 InPoolIndex, uint32 InBlockSize, const FArenaParams& LocalArenaParams);
bool CanPushToFront(uint32 InPoolIndex, uint32 InBlockSize, const FArenaParams& LocalArenaParams);
void* PopFromFront(FMallocBinnedGPU& Allocator, uint32 InPoolIndex);
FBundleNode* RecyleFull(FArenaParams& LocalArenaParams, FGlobalRecycler& GGlobalRecycler, uint32 InPoolIndex);
bool ObtainPartial(FArenaParams& LocalArenaParams, FGlobalRecycler& GGlobalRecycler, uint32 InPoolIndex);
FBundleNode* PopBundles(uint32 InPoolIndex);
private:
FBundle PartialBundle;
FBundle FullBundle;
};
// 逐线程的空闲块列表
struct FPerThreadFreeBlockLists
{
static FPerThreadFreeBlockLists* Get(uint32 BinnedGPUTlsSlot);
static void SetTLS(FMallocBinnedGPU& Allocator);
static int64 ClearTLS(FMallocBinnedGPU& Allocator);
void* Malloc(FMallocBinnedGPU& Allocator, uint32 InPoolIndex);
bool Free(FMallocBinnedGPU& Allocator, void* InPtr, uint32 InPoolIndex, uint32 InBlockSize, const FArenaParams& LocalArenaParams);
bool CanFree(uint32 InPoolIndex, uint32 InBlockSize, const FArenaParams& LocalArenaParams);
FBundleNode* RecycleFullBundle(FArenaParams& LocalArenaParams, FGlobalRecycler& GlobalRecycler, uint32 InPoolIndex);
bool ObtainRecycledPartial(FArenaParams& LocalArenaParams, FGlobalRecycler& GlobalRecycler, uint32 InPoolIndex);
FBundleNode* PopBundles(uint32 InPoolIndex);
int64 AllocatedMemory;
TArray<FFreeBlockList> FreeLists;
};
// 全局回收器
struct FGlobalRecycler
{
void Init(uint32 PoolCount);
bool PushBundle(uint32 NumCachedBundles, uint32 InPoolIndex, FBundleNode* InBundle);
FBundleNode* PopBundle(uint32 NumCachedBundles, uint32 InPoolIndex);
private:
struct FPaddedBundlePointer
{
FBundleNode* FreeBundles[BINNEDGPU_MAX_GMallocBinnedGPUMaxBundlesBeforeRecycle];
};
TArray<FPaddedBundlePointer> Bundles;
};
uint64 PoolIndexFromPtr(const void* Ptr);
uint8* PoolBasePtr(uint32 InPoolIndex);
uint64 PoolIndexFromPtrChecked(const void* Ptr);
bool IsOSAllocation(const void* Ptr);
void* BlockOfBlocksPointerFromContainedPtr(const void* Ptr, uint8 PagesPlatformForBlockOfBlocks, uint32& OutBlockOfBlocksIndex);
uint8* BlockPointerFromIndecies(uint32 InPoolIndex, uint32 BlockOfBlocksIndex, uint32 BlockOfBlocksSize);
FPoolInfoSmall* PushNewPoolToFront(FMallocBinnedGPU& Allocator, uint32 InBlockSize, uint32 InPoolIndex, uint32& OutBlockOfBlocksIndex);
FPoolInfoSmall* GetFrontPool(FPoolTable& Table, uint32 InPoolIndex, uint32& OutBlockOfBlocksIndex);
bool AdjustSmallBlockSizeForAlignment(SIZE_T& InOutSize, uint32 Alignment);
public:
FArenaParams& GetParams();
void InitMallocBinned();
virtual bool IsInternallyThreadSafe() const override;
virtual void* Malloc(SIZE_T Size, uint32 Alignment) override;
virtual void* Realloc(void* Ptr, SIZE_T NewSize, uint32 Alignment) override;
virtual void Free(void* Ptr) override;
virtual bool GetAllocationSize(void *Ptr, SIZE_T &SizeOut) override;
virtual SIZE_T QuantizeSize(SIZE_T Count, uint32 Alignment) override;
virtual bool ValidateHeap() override;
virtual void Trim(bool bTrimThreadCaches) override;
virtual void SetupTLSCachesOnCurrentThread() override;
virtual void ClearAndDisableTLSCachesOnCurrentThread() override;
virtual const TCHAR* GetDescriptiveName() override;
void FlushCurrentThreadCache();
void* MallocExternal(SIZE_T Size, uint32 Alignment);
void FreeExternal(void *Ptr);
bool GetAllocationSizeExternal(void* Ptr, SIZE_T& SizeOut);
MBG_STAT(int64 GetTotalAllocatedSmallPoolMemory();)
virtual void GetAllocatorStats(FGenericMemoryStats& out_Stats) override;
virtual void DumpAllocatorStats(class FOutputDevice& Ar) override;
uint32 BoundSizeToPoolIndex(SIZE_T Size);
uint32 PoolIndexToBlockSize(uint32 PoolIndex);
void Commit(uint32 InPoolIndex, void *Ptr, SIZE_T Size);
void Decommit(uint32 InPoolIndex, void *Ptr, SIZE_T Size);
(...)
// Pool tables for different pool sizes
TArray<FPoolTable> SmallPoolTables;
uint32 SmallPoolInfosPerPlatformPage;
PoolHashBucket* HashBuckets;
PoolHashBucket* HashBucketFreeList;
uint64 NumLargePoolsPerPage;
FCriticalSection Mutex;
FGlobalRecycler GGlobalRecycler;
FPtrToPoolMapping PtrToPoolMapping;
FArenaParams ArenaParams;
TArray<uint16> SmallBlockSizesReversedShifted; // this is reversed to get the smallest elements on our main cache line
uint32 BinnedGPUTlsSlot;
uint64 PoolSearchDiv; // if this is zero, the VM turned out to be contiguous anyway so we use a simple subtract and shift
uint8* HighestPoolBaseVMPtr; // this is a duplicate of PoolBaseVMPtr[ArenaParams.PoolCount - 1]
FPlatformMemory::FPlatformVirtualMemoryBlock PoolBaseVMBlock;
TArray<uint8*> PoolBaseVMPtr;
TArray<FPlatformMemory::FPlatformVirtualMemoryBlock> PoolBaseVMBlocks;
// Mapping of sizes to small table indices
TArray<uint8> MemSizeToIndex;
FCriticalSection FreeBlockListsRegistrationMutex;
TArray<FPerThreadFreeBlockLists*> RegisteredFreeBlockLists;
TArray<void*> MallocedPointers;
};
// GPUDefragAllocator.h
// 简单的最适合分配器,无论何时何地都可以拆分和合并。不是线程安全的。使用TMap查找给定指针的内存块(可能与malloc/free主线程冲突)使用单独的链接列表进行自由分配,假设由于合并导致相对较少的自由块.
class FGPUDefragAllocator
{
public:
typedef TDoubleLinkedList<FAsyncReallocationRequest*> FRequestList;
typedef TDoubleLinkedList<FAsyncReallocationRequest*>::TDoubleLinkedListNode FRequestNode;
// 分配器设置的容器
struct FSettings
{
int32 MaxDefragRelocations;
int32 MaxDefragDownShift;
int32 OverlappedBandwidthScale;
};
enum EMemoryElementType
{
MET_Allocated,
MET_Free,
MET_Locked,
MET_Relocating,
MET_Resizing,
MET_Resized,
MET_Max
};
struct FMemoryLayoutElement
{
int32 Size;
EMemoryElementType Type;
};
// 分配器重新分配统计信息的容器。
struct FRelocationStats
{
int64 NumBytesRelocated;
int64 NumBytesDownShifted;
int64 LargestHoleSize;
int32 NumRelocations;
int32 NumHoles;
int32 NumLockedChunks;
};
// 包含单个分配或空闲块的信息。
class FMemoryChunk
{
public:
uint8* Base;
int64 Size;
int64 OrigSize;
bool bIsAvailable;
int32 LockCount;
uint16 DefragCounter;
// 允许访问FBestFitAllocator成员,如FirstChunk、FirstFreeChunk和LastChunk。
FGPUDefragAllocator& BestFitAllocator;
FMemoryChunk* PreviousChunk;
FMemoryChunk* NextChunk;
FMemoryChunk* PreviousFreeChunk;
FMemoryChunk* NextFreeChunk;
uint32 SyncIndex;
int64 SyncSize;
void* UserPayload;
TStatId Stat;
bool bTail;
};
virtual void* Allocate(int64 AllocationSize, int32 Alignment, TStatId InStat, bool bAllowFailure);
virtual void Free(void* Pointer);
virtual void Lock(const void* Pointer);
virtual void Unlock(const void* Pointer);
void* Reallocate(void* OldBaseAddress, int64 NewSize);
void DefragmentMemory(FRelocationStats& Stats);
void SetUserPayload(const void* Pointer, void* UserPayload);
void* GetUserPayload(const void* Pointer);
int64 GetAllocatedSize(void* Pointer);
bool IsValidPoolMemory(const void* Pointer) const;
void DumpAllocs(FOutputDevice& Ar = *GLog);
int64 GetTotalSize() const;
int32 GetLargestAvailableAllocation(int32* OutNumFreeChunks = nullptr);
uint32 GetBlockedCycles() const
bool InBenchmarkMode() const
bool GetTextureMemoryVisualizeData(FColor* TextureData, int32 SizeX, int32 SizeY, int32 Pitch, const int32 PixelSize);
void GetMemoryLayout(TArray<FMemoryLayoutElement>& MemoryLayout);
virtual int32 Tick(FRelocationStats& Stats, bool bPanicDefrag);
bool FinishAllRelocations();
void BlockOnAsyncReallocation(FAsyncReallocationRequest* Request);
void CancelAsyncReallocation(FAsyncReallocationRequest* Request, const void* CurrentBaseAddress);
static bool IsAligned(const volatile void* Ptr, const uint32 Alignment);
int32 GetAllocationAlignment() const;
(...)
};
下面涉及了动态分辨率:
// DynamicResolutionProxy.h
// 渲染线程代理是动态解析的启发式方法
class FDynamicResolutionHeuristicProxy
{
public:
static constexpr uint64 kInvalidEntryId = ~uint64(0);
void Reset_RenderThread();
uint64 CreateNewPreviousFrameTimings_RenderThread(float GameThreadTimeMs, float RenderThreadTimeMs);
void CommitPreviousFrameGPUTimings_RenderThread(...);
void RefreshCurentFrameResolutionFraction_RenderThread();
float GetResolutionFractionUpperBound() const;
float QueryCurentFrameResolutionFraction_RenderThread() const;
float GetResolutionFractionApproximation_GameThread() const;
static TSharedPtr< class IDynamicResolutionState > CreateDefaultState();
private:
struct FrameHistoryEntry
{
float ResolutionFraction;
float GameThreadTimeMs;
float RenderThreadTimeMs;
float TotalFrameGPUBusyTimeMs;
float GlobalDynamicResolutionTimeMs;
bool bGPUTimingsHaveCPUBubbles;
};
TArray<FrameHistoryEntry> History;
int32 PreviousFrameIndex;
int32 HistorySize;
int32 NumberOfFramesSinceScreenPercentageChange;
int32 IgnoreFrameRemainingCount;
float CurrentFrameResolutionFraction;
uint64 FrameCounter;
};
下面代码吗涉及了多GPU、GPU掩码等逻辑:
// MultiGPU.h
/** A mask where each bit is a GPU index. Can not be empty so that non SLI platforms can optimize it to be always 1. */
struct FRHIGPUMask
{
private:
uint32 GPUMask;
uint32 ToIndex() const;
bool HasSingleIndex() const;
uint32 GetLastIndex() const;
uint32 GetFirstIndex() const;
bool Contains(uint32 GPUIndex) const;
bool ContainsAll(const FRHIGPUMask& Rhs) const;
bool Intersects(const FRHIGPUMask& Rhs) const;
bool operator ==(const FRHIGPUMask& Rhs) const;
bool operator !=(const FRHIGPUMask& Rhs) const;
uint32 GetNative() const;
static const FRHIGPUMask GPU0() { return FRHIGPUMask(1); }
static const FRHIGPUMask All() { return FRHIGPUMask((1 << GNumExplicitGPUsForRendering) - 1); }
static const FRHIGPUMask FilterGPUsBefore(uint32 GPUIndex) { return FRHIGPUMask(~((1u << GPUIndex) - 1)) & All(); }
struct FIterator
{
explicit FIterator(const uint32 InGPUMask) : GPUMask(InGPUMask), FirstGPUIndexInMask(0);
explicit FIterator(const FRHIGPUMask& InGPUMask) : FIterator(InGPUMask.GPUMask);
FIterator& operator++();
FIterator operator++(int);
private:
uint32 GPUMask;
unsigned long FirstGPUIndexInMask;
};
friend FRHIGPUMask::FIterator begin(const FRHIGPUMask& NodeMask);
friend FRHIGPUMask::FIterator end(const FRHIGPUMask& NodeMask);
};
// GPU掩码实用程序,用于获取有关AFR组和兄弟姐妹的信息。AFR组是一起在同一帧上工作的一组GPU。AFR兄弟是其他组中的GPU,在后续帧上执行相同的工作。例如,在不同帧上渲染相同视图的两个GPU是AFR同级。对于具有2个AFR组的4 GPU设置:每个AFR组有2个GPU。0b1010和0b0101是两个组, 每个GPU有一个同级GPU。0b1100和0b0011是兄弟姐妹。
struct AFRUtils
{
static inline uint32 GetNumGPUsPerGroup();
static inline uint32 GetGroupIndex(uint32 GPUIndex);
static inline uint32 GetIndexWithinGroup(uint32 GPUIndex);
static inline uint32 GetNextSiblingGPUIndex(uint32 GPUIndex);
static inline FRHIGPUMask GetNextSiblingGPUMask(FRHIGPUMask InGPUMask);
static inline uint32 GetPrevSiblingGPUIndex(uint32 GPUIndex);
static inline FRHIGPUMask GetPrevSiblingGPUMask(FRHIGPUMask InGPUMask);
static inline FRHIGPUMask GetGPUMaskForGroup(uint32 GPUIndex);
static inline FRHIGPUMask GetGPUMaskForGroup(FRHIGPUMask InGPUMask);
static inline FRHIGPUMask GetGPUMaskWithSiblings(uint32 GPUIndex);
static inline FRHIGPUMask GetGPUMaskWithSiblings(FRHIGPUMask InGPUMask);
#if WITH_MGPU
static TArray<FRHIGPUMask, TFixedAllocator<MAX_NUM_GPUS>> GroupMasks;
static TArray<FRHIGPUMask, TFixedAllocator<MAX_NUM_GPUS>> SiblingMasks;
#endif
};
以下类型或接口涉及了GPU厂商、驱动和特性:
// RHIDefinitions.h
enum class EGpuVendorId
{
Unknown = -1,
NotQueried = 0,
Amd = 0x1002,
ImgTec = 0x1010,
Nvidia = 0x10DE,
Arm = 0x13B5,
Broadcom = 0x14E4,
Qualcomm = 0x5143,
Intel = 0x8086,
Apple = 0x106B,
Vivante = 0x7a05,
VeriSilicon = 0x1EB1,
Kazan = 0x10003, // VkVendorId
Codeplay = 0x10004, // VkVendorId
Mesa = 0x10005, // VkVendorId
};
inline bool RHIHasTiledGPU(const FStaticShaderPlatform Platform);
inline EGpuVendorId RHIConvertToGpuVendorId(uint32 VendorId);
class FGenericDataDrivenShaderPlatformInfo
{
FName Language;
ERHIFeatureLevel::Type MaxFeatureLevel;
uint32 bIsMobile: 1;
uint32 bIsMetalMRT: 1;
uint32 bIsPC: 1;
uint32 bIsConsole: 1;
uint32 bIsAndroidOpenGLES: 1;
uint32 bSupportsDebugViewShaders : 1;
uint32 bSupportsMobileMultiView: 1;
uint32 bSupportsArrayTextureCompression : 1;
uint32 bSupportsDistanceFields: 1; // used for DFShadows and DFAO - since they had the same checks
uint32 bSupportsDiaphragmDOF: 1;
uint32 bSupportsRGBColorBuffer: 1;
uint32 bSupportsCapsuleShadows: 1;
uint32 bSupportsPercentageCloserShadows : 1;
uint32 bSupportsVolumetricFog: 1; // also used for FVVoxelization
uint32 bSupportsIndexBufferUAVs: 1;
uint32 bSupportsInstancedStereo: 1;
uint32 bSupportsMultiView: 1;
uint32 bSupportsMSAA: 1;
uint32 bSupports4ComponentUAVReadWrite: 1;
uint32 bSupportsRenderTargetWriteMask: 1;
uint32 bSupportsRayTracing: 1;
uint32 bSupportsRayTracingProceduralPrimitive : 1;
uint32 bSupportsRayTracingIndirectInstanceData : 1; // Whether instance transforms can be copied from the GPU to the TLAS instances buffer
uint32 bSupportsHighEndRayTracingReflections : 1; // Whether fully-featured RT reflections can be used on the platform (with multi-bounce, translucency, etc.)
uint32 bSupportsPathTracing : 1; // Whether real-time path tracer is supported on this platform (avoids compiling unnecessary shaders)
uint32 bSupportsGPUSkinCache: 1;
uint32 bSupportsGPUScene : 1;
uint32 bSupportsByteBufferComputeShaders : 1;
uint32 bSupportsPrimitiveShaders : 1;
uint32 bSupportsUInt64ImageAtomics : 1;
uint32 bRequiresVendorExtensionsForAtomics : 1;
uint32 bSupportsNanite : 1;
uint32 bSupportsLumenGI : 1;
uint32 bSupportsSSDIndirect : 1;
uint32 bSupportsTemporalHistoryUpscale : 1;
uint32 bSupportsRTIndexFromVS : 1;
uint32 bSupportsWaveOperations : 1; // Whether HLSL SM6 shader wave intrinsics are supported
uint32 bSupportsIntrinsicWaveOnce : 1;
uint32 bSupportsConservativeRasterization : 1;
uint32 bRequiresExplicit128bitRT : 1;
uint32 bSupportsGen5TemporalAA : 1;
uint32 bTargetsTiledGPU: 1;
uint32 bNeedsOfflineCompiler: 1;
uint32 bSupportsComputeFramework : 1;
uint32 bSupportsAnisotropicMaterials : 1;
uint32 bSupportsDualSourceBlending : 1;
uint32 bRequiresGeneratePrevTransformBuffer : 1;
uint32 bRequiresRenderTargetDuringRaster : 1;
uint32 bRequiresDisableForwardLocalLights : 1;
uint32 bCompileSignalProcessingPipeline : 1;
uint32 bSupportsMeshShadersTier0 : 1;
uint32 bSupportsMeshShadersTier1 : 1;
uint32 MaxMeshShaderThreadGroupSize : 10;
uint32 bSupportsPerPixelDBufferMask : 1;
uint32 bIsHlslcc : 1;
uint32 bSupportsDxc : 1; // Whether DirectXShaderCompiler (DXC) is supported
uint32 bSupportsVariableRateShading : 1;
uint32 NumberOfComputeThreads : 10;
uint32 bWaterUsesSimpleForwardShading : 1;
uint32 bNeedsToSwitchVerticalAxisOnMobileOpenGL : 1;
uint32 bSupportsHairStrandGeometry : 1;
uint32 bSupportsDOFHybridScattering : 1;
uint32 bNeedsExtraMobileFrames : 1;
uint32 bSupportsHZBOcclusion : 1;
uint32 bSupportsWaterIndirectDraw : 1;
uint32 bSupportsAsyncPipelineCompilation : 1;
uint32 bSupportsManualVertexFetch : 1;
uint32 bRequiresReverseCullingOnMobile : 1;
uint32 bOverrideFMaterial_NeedsGBufferEnabled : 1;
uint32 bSupportsMobileDistanceField : 1;
uint32 bSupportsFFTBloom : 1;
uint32 bSupportsInlineRayTracing : 1;
uint32 bSupportsRayTracingShaders : 1;
uint32 bSupportsVertexShaderLayer : 1;
uint32 bSupportsVolumeTextureAtomics : 1;
private:
static FGenericDataDrivenShaderPlatformInfo Infos[SP_NumPlatforms];
(...)
}
19.11.4 其它
以下代码提供了部分硬件的信息和操作:
// GenericPlatformMisc.h
struct FGenericPlatformMisc
{
// 设备/硬件
static FString GetDeviceId();
static FString GetUniqueAdvertisingId();
static void SubmitErrorReport( const TCHAR* InErrorHist, EErrorReportMode::Type InMode );
static bool IsRemoteSession();
static bool IsDebuggerPresent();
static EProcessDiagnosticFlags GetProcessDiagnostics();
static FString GetCPUVendor();
static uint32 GetCPUInfo();
static bool HasNonoptionalCPUFeatures();
static bool NeedsNonoptionalCPUFeaturesCheck();
static FString GetCPUBrand();
static FString GetCPUChipset();
static FString GetPrimaryGPUBrand();
static FString GetDeviceMakeAndModel();
static struct FGPUDriverInfo GetGPUDriverInfo(const FString& DeviceDescription);
static void PrefetchBlock(const void* InPtr, int32 NumBytes = 1);
static void Prefetch(void const* x, int32 offset = 0);
static const TCHAR* GetDefaultDeviceProfileName();
static int GetBatteryLevel();
static void SetBrightness(float bBright);
static float GetBrightness();
static bool SupportsBrightness();
static bool IsInLowPowerMode();
static float GetDeviceTemperatureLevel();
static inline int32 GetMaxRefreshRate();
static inline int32 GetMaxSyncInterval();
static bool IsPGOEnabled();
static TArray<uint8> GetSystemFontBytes();
static bool HasActiveWiFiConnection();
static ENetworkConnectionType GetNetworkConnectionType();
static bool HasVariableHardware();
static bool HasPlatformFeature(const TCHAR* FeatureName);
static bool IsRunningOnBattery();
static EDeviceScreenOrientation GetDeviceOrientation();
static void SetDeviceOrientation(EDeviceScreenOrientation NewDeviceOrientation);
static int32 GetDeviceVolume();
// 内存
static void MemoryBarrier();
static void SetMemoryWarningHandler(void (* Handler)(const FGenericMemoryWarningContext& Context));
static bool HasMemoryWarningHandler();
// I/O
static void InitTaggedStorage(uint32 NumTags);
static void ShutdownTaggedStorage();
static void TagBuffer(const char* Label, uint32 Category, const void* Buffer, size_t BufferSize);
static bool SetStoredValues(const FString& InStoreId, const FString& InSectionName, const TMap<FString, FString>& InKeyValues);
static bool SetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, const FString& InValue);
static bool GetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, FString& OutValue);
static bool DeleteStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName);
static bool DeleteStoredSection(const FString& InStoreId, const FString& InSectionName);
static TArray<FCustomChunk> GetOnDemandChunksForPakchunkIndices(const TArray<int32>& PakchunkIndices);
static TArray<FCustomChunk> GetAllOnDemandChunks();
static TArray<FCustomChunk> GetAllLanguageChunks();
static TArray<FCustomChunk> GetCustomChunksByType(ECustomChunkType DesiredChunkType);
static void ParseChunkIdPakchunkIndexMapping(TArray<FString> ChunkIndexRedirects, TMap<int32, int32>& OutMapping);
static int32 GetChunkIDFromPakchunkIndex(int32 PakchunkIndex);
static int32 GetPakchunkIndexFromPakFile(const FString& InFilename);
static FText GetFileManagerName();
static bool IsPackagedForDistribution();
static FString LoadTextFileFromPlatformPackage(const FString& RelativePath);
static bool FileExistsInPlatformPackage(const FString& RelativePath);
static bool Expand16BitIndicesTo32BitOnLoad();
static void GetNetworkFileCustomData(TMap<FString,FString>& OutCustomPlatformData);
static bool SupportsBackbufferSampling();
(...)
};
// GenericPlatformApplicationMisc.h
struct FGenericPlatformApplicationMisc
{
// 模块/上下文/设备
static void LoadPreInitModules();
static void LoadStartupModules();
static FOutputDeviceConsole* CreateConsoleOutputDevice();
static FOutputDeviceError* GetErrorOutputDevice();
static FFeedbackContext* GetFeedbackContext();
static bool IsThisApplicationForeground();
static void RequestMinimize();
static bool RequiresVirtualKeyboard();
static void PumpMessages(bool bFromMainLoop);
// 屏幕/窗口
static void PreventScreenSaver();
static bool IsScreensaverEnabled();
static bool ControlScreensaver(EScreenSaverAction Action);
static struct FLinearColor GetScreenPixelColor(const FVector2D& InScreenPos, float InGamma);
static bool GetWindowTitleMatchingText(const TCHAR* TitleStartsWith, FString& OutTitle);
static void SetHighDPIMode();
static float GetDPIScaleFactorAtPoint(float X, float Y);
static bool IsHighDPIAwarenessEnabled();
static bool AnchorWindowWindowPositionTopLeft();
static EScreenPhysicalAccuracy GetPhysicalScreenDensity(int32& OutScreenDensity);
static EScreenPhysicalAccuracy ComputePhysicalScreenDensity(int32& OutScreenDensity);
static EScreenPhysicalAccuracy ConvertInchesToPixels(T Inches, T2& OutPixels);
static EScreenPhysicalAccuracy ConvertPixelsToInches(T Pixels, T2& OutInches);
// 控制器
static void SetGamepadsAllowed(bool bAllowed);
static void SetGamepadsBlockDeviceFeedback(bool bAllowed);
static void ResetGamepadAssignments();
static void ResetGamepadAssignmentToController(int32 ControllerId);
static bool IsControllerAssignedToGamepad(int32 ControllerId);
static FString GetGamepadControllerName(int32 ControllerId);
static class UTexture2D* GetGamepadButtonGlyph(...);
static void EnableMotionData(bool bEnable);
static bool IsMotionDataEnabled();
(...)
};
// 硬件查询结果
struct FHardwareSurveyResults
{
static const int32 MaxDisplayCount = 8;
static const int32 MaxStringLength = 260;
TCHAR Platform[MaxStringLength];
TCHAR OSVersion[MaxStringLength];
TCHAR OSSubVersion[MaxStringLength];
uint32 OSBits;
TCHAR OSLanguage[MaxStringLength];
TCHAR RenderingAPI[MaxStringLength];
TCHAR MultimediaAPI_DEPRECATED[MaxStringLength];
uint32 HardDriveGB;
uint32 HardDriveFreeMB;
uint32 MemoryMB;
float CPUPerformanceIndex;
float GPUPerformanceIndex;
float RAMPerformanceIndex;
uint32 bIsLaptopComputer:1;
uint32 bIsRemoteSession:1;
uint32 CPUCount;
float CPUClockGHz;
TCHAR CPUBrand[MaxStringLength];
TCHAR CPUNameString[MaxStringLength];
uint32 CPUInfo;
uint32 DisplayCount;
FHardwareDisplay Displays[MaxDisplayCount];
FGPUAdpater RHIAdapter;
uint32 ErrorCount;
TCHAR LastSurveyError[MaxStringLength];
TCHAR LastSurveyErrorDetail[MaxStringLength];
TCHAR LastPerformanceIndexError[MaxStringLength];
TCHAR LastPerformanceIndexErrorDetail[MaxStringLength];
FSynthBenchmarkResults SynthBenchmark;
};
// 不同的平台实现获取FHardwareSurveyResults。
struct APPLICATIONCORE_API FGenericPlatformSurvey
{
static bool GetSurveyResults(FHardwareSurveyResults& OutResults, bool bWait);
};
// HardwareInfo.h
// 硬件信息
struct ENGINE_API FHardwareInfo
{
static void RegisterHardwareInfo( const FName SpecIdentifier, const FString& HardwareInfo );
static FString GetHardwareInfo(const FName SpecIdentifier);
static const FString GetHardwareDetailsString();
};
下面代码提供了性能检测功能:
// GenericPlatformSurvey.h
struct FSynthBenchmarkStat
{
// 计算线性性能指数(>0),在硬件良好的情况下约为100,但数字可能更高.
float ComputePerfIndex() const;
void SetMeasuredTime(const FTimeSample& TimeSample, float InConfidence = 90);
float GetNormalizedTime() const;
float GetMeasuredTotalTime() const;
float GetConfidence() const;
float GetWeight() const;
private:
// -1(如果未定义),以秒为单位,有助于查看测试是否运行时间过长(某些较慢的GPU可能超时).
float MeasuredTotalTime;
// -1(如果未定义),则取决于测试(例如s/g像素),WorkScale被划分.
float MeasuredNormalizedTime;
// -1(如果未定义),则为标准GPU上预期的定时值(索引值100,此处为NVidia 670).
float IndexNormalizedTime;
// 0..100,100:完全自信
float Confidence;
// 1为正常权重,0为无权重,>1为无边界附加权重.
float Weight;
};
下面的代码提供了磁盘的利用率追踪:
// DiskUtilizationTracker.h
struct FDiskUtilizationTracker
{
struct UtilizationStats
{
double GetOverallThroughputBS() const;
double GetOverallThroughputMBS() const;
double GetReadThrougputBS() const;
double GetReadThrougputMBS() const;
double GetTotalIdleTimeInSeconds() const;
double GetTotalIOTimeInSeconds() const;
double GetPercentTimeIdle() const;
uint64 TotalReads;
uint64 TotalSeeks;
uint64 TotalBytesRead;
uint64 TotalSeekDistance;
double TotalIOTime;
double TotalIdleTime;
};
UtilizationStats LongTermStats;
UtilizationStats ShortTermStats;
FCriticalSection CriticalSection;
uint64 IdleStartCycle;
uint64 ReadStartCycle;
uint64 InFlightBytes;
int32 InFlightReads;
FThreadSafeBool bResetShortTermStats;
void StartRead(uint64 InReadBytes, uint64 InSeekDistance = 0);
void FinishRead();
uint32 GetOutstandingRequests() const;
const struct UtilizationStats& GetLongTermStats() const;
const struct UtilizationStats& GetShortTermStats() const;
void ResetShortTermStats();
private:
static float GetThrottleRateMBS();
static constexpr float PrintFrequencySeconds = 0.5f;
};
下面提供了存储IO相关的信息和接口:
// IoStore.h
// I/O存储TOC标头。
struct FIoStoreTocHeader
{
static constexpr char TocMagicImg[] = "-==--==--==--==-";
uint8 TocMagic[16];
uint8 Version;
uint8 Reserved0 = 0;
uint16 Reserved1 = 0;
uint32 TocHeaderSize;
uint32 TocEntryCount;
uint32 TocCompressedBlockEntryCount;
uint32 TocCompressedBlockEntrySize; // For sanity checking
uint32 CompressionMethodNameCount;
uint32 CompressionMethodNameLength;
uint32 CompressionBlockSize;
uint32 DirectoryIndexSize;
uint32 PartitionCount = 0;
FIoContainerId ContainerId;
FGuid EncryptionKeyGuid;
EIoContainerFlags ContainerFlags;
uint8 Reserved3 = 0;
uint16 Reserved4 = 0;
uint32 TocChunkPerfectHashSeedsCount = 0;
uint64 PartitionSize = 0;
uint32 TocChunksWithoutPerfectHashCount = 0;
uint32 Reserved7 = 0;
uint64 Reserved8[5] = { 0 };
};
// 组合偏移量和长度。
struct FIoOffsetAndLength
{
public:
inline uint64 GetOffset() const;
inline uint64 GetLength() const;
inline void SetOffset(uint64 Offset);
inline void SetLength(uint64 Length);
private:
uint8 OffsetAndLength[5 + 5];
};
// TOC条目元数据
struct FIoStoreTocEntryMeta
{
FIoChunkHash ChunkHash;
FIoStoreTocEntryMetaFlags Flags;
};
// 压缩块条目
struct FIoStoreTocCompressedBlockEntry
{
static constexpr uint32 OffsetBits = 40;
static constexpr uint64 OffsetMask = (1ull << OffsetBits) - 1ull;
static constexpr uint32 SizeBits = 24;
static constexpr uint32 SizeMask = (1 << SizeBits) - 1;
static constexpr uint32 SizeShift = 8;
inline uint64 GetOffset() const;
inline void SetOffset(uint64 InOffset);
inline uint32 GetCompressedSize() const;
inline void SetCompressedSize(uint32 InSize);
inline uint32 GetUncompressedSize() const;
inline void SetUncompressedSize(uint32 InSize);
inline uint8 GetCompressionMethodIndex() const;
inline void SetCompressionMethodIndex(uint8 InIndex);
private:
uint8 Data[5 + 3 + 3 + 1];
};
// TOC资源读取操作
enum class EIoStoreTocReadOptions
{
Default,
ReadDirectoryIndex = (1 << 0),
ReadTocMeta = (1 << 1),
ReadAll = ReadDirectoryIndex | ReadTocMeta
};
ENUM_CLASS_FLAGS(EIoStoreTocReadOptions);
// TOC数据容器
struct FIoStoreTocResource
{
enum { CompressionMethodNameLen = 32 };
FIoStoreTocHeader Header;
TArray<FIoChunkId> ChunkIds;
TArray<FIoOffsetAndLength> ChunkOffsetLengths;
TArray<int32> ChunkPerfectHashSeeds;
TArray<int32> ChunkIndicesWithoutPerfectHash;
TArray<FIoStoreTocCompressedBlockEntry> CompressionBlocks;
TArray<FName> CompressionMethods;
FSHAHash SignatureHash;
TArray<FSHAHash> ChunkBlockSignatures;
TArray<FIoStoreTocEntryMeta> ChunkMetas;
TArray<uint8> DirectoryIndexBuffer;
static FIoStatus Read(const TCHAR* TocFilePath, EIoStoreTocReadOptions ReadOptions, FIoStoreTocResource& OutTocResource);
static TIoStatusOr<uint64> Write(const TCHAR* TocFilePath, FIoStoreTocResource& TocResource, ...);
static uint64 HashChunkIdWithSeed(int32 Seed, const FIoChunkId& ChunkId);
};
// 以下是IO的目录、文件、索引相关
// IoDirectoryIndex.h
struct FIoDirectoryIndexEntry
{
uint32 Name = ~uint32(0);
uint32 FirstChildEntry = ~uint32(0);
uint32 NextSiblingEntry = ~uint32(0);
uint32 FirstFileEntry = ~uint32(0);
};
struct FIoFileIndexEntry
{
uint32 Name = ~uint32(0);
uint32 NextFileEntry = ~uint32(0);
uint32 UserData = 0;
};
struct FIoDirectoryIndexResource
{
FString MountPoint;
TArray<FIoDirectoryIndexEntry> DirectoryEntries;
TArray<FIoFileIndexEntry> FileEntries;
TArray<FString> StringTable;
};
class FIoDirectoryIndexWriter
{
public:
void SetMountPoint(FString InMountPoint);
uint32 AddFile(const FString& InFileName);
void SetFileUserData(uint32 InFileEntryIndex, uint32 InUserData);
void Flush(TArray<uint8>& OutBuffer, FAES::FAESKey InEncryptionKey);
private:
uint32 GetDirectory(uint32 DirectoryName, uint32 Parent);
uint32 CreateDirectory(const FStringView& DirectoryName, uint32 Parent);
uint32 GetNameIndex(const FStringView& String);
uint32 AddFile(const FStringView& FileName, uint32 Directory);
static bool IsValid(uint32 Index);
FString MountPoint;
TArray<FIoDirectoryIndexEntry> DirectoryEntries;
TArray<FIoFileIndexEntry> FileEntries;
TMap<FString, uint32> StringToIndex;
TArray<FString> Strings;
};
// IoDispatcherPrivate.h
class FIoBatchImpl
{
public:
TFunction<void()> Callback;
FEvent* Event = nullptr;
FGraphEventRef GraphEvent;
TAtomic<uint32> UnfinishedRequestsCount;
};
下面代码提供了平台无关的亲缘性操作:
// GenericPlatformAffinity.h
class FGenericPlatformAffinity
{
public:
static const uint64 GetMainGameMask();
static const uint64 GetRenderingThreadMask();
static const uint64 GetRHIThreadMask();
static const uint64 GetRHIFrameOffsetThreadMask();
static const uint64 GetRTHeartBeatMask();
static const uint64 GetPoolThreadMask();
static const uint64 GetTaskGraphThreadMask();
static const uint64 GetAudioThreadMask();
static const uint64 GetNoAffinityMask();
static const uint64 GetTaskGraphBackgroundTaskMask();
static const uint64 GetTaskGraphHighPriorityTaskMask();
static const uint64 GetAsyncLoadingThreadMask();
static const uint64 GetIoDispatcherThreadMask();
static const uint64 GetTraceThreadMask();
static EThreadPriority GetRenderingThreadPriority();
static EThreadCreateFlags GetRenderingThreadFlags();
static EThreadPriority GetRHIThreadPriority();
static EThreadPriority GetGameThreadPriority();
static EThreadCreateFlags GetRHIThreadFlags();
static EThreadPriority GetTaskThreadPriority();
static EThreadPriority GetTaskBPThreadPriority();
};
下面定义了许多硬件、ISA、操作系统、编译器、图形API及它们的特性相关的宏:
// Platform.h
PLATFORM_WINDOWS
PLATFORM_XBOXONE
PLATFORM_MAC
PLATFORM_MAC_X86
PLATFORM_MAC_ARM64
PLATFORM_PS4
PLATFORM_IOS
PLATFORM_TVOS
PLATFORM_ANDROID
PLATFORM_ANDROID_ARM
PLATFORM_ANDROID_ARM64
PLATFORM_ANDROID_X86
PLATFORM_ANDROID_X64
PLATFORM_APPLE
PLATFORM_LINUX
PLATFORM_LINUXARM64
PLATFORM_SWITCH
PLATFORM_FREEBSD
PLATFORM_UNIX
PLATFORM_MICROSOFT
PLATFORM_HOLOLENS
PLATFORM_CPU_X86_FAMILY
PLATFORM_CPU_ARM_FAMILY
PLATFORM_COMPILER_CLANG
PLATFORM_DESKTOP
PLATFORM_64BITS
PLATFORM_LITTLE_ENDIAN
PLATFORM_SUPPORTS_UNALIGNED_LOADS
PLATFORM_EXCEPTIONS_DISABLED
PLATFORM_SUPPORTS_PRAGMA_PACK
PLATFORM_ENABLE_VECTORINTRINSICS
PLATFORM_MAYBE_HAS_SSE4_1
PLATFORM_MAYBE_HAS_AVX
PLATFORM_ALWAYS_HAS_AVX_2
PLATFORM_ALWAYS_HAS_FMA3
PLATFORM_HAS_CPUID
PLATFORM_ENABLE_POPCNT_INTRINSIC
PLATFORM_ENABLE_VECTORINTRINSICS_NEON
PLATFORM_USE_LS_SPEC_FOR_WIDECHAR
PLATFORM_USE_SYSTEM_VSWPRINTF
PLATFORM_COMPILER_DISTINGUISHES_INT_AND_LONG
PLATFORM_COMPILER_HAS_GENERIC_KEYWORD
PLATFORM_COMPILER_HAS_DEFAULTED_FUNCTIONS
PLATFORM_COMPILER_COMMON_LANGUAGE_RUNTIME_COMPILATION
PLATFORM_COMPILER_HAS_TCHAR_WMAIN
PLATFORM_COMPILER_HAS_DECLTYPE_AUTO
PLATFORM_COMPILER_HAS_IF_CONSTEXPR
PLATFORM_COMPILER_HAS_FOLD_EXPRESSIONS
PLATFORM_TCHAR_IS_4_BYTES
PLATFORM_WCHAR_IS_4_BYTES
PLATFORM_TCHAR_IS_CHAR16
PLATFORM_UCS2CHAR_IS_UTF16CHAR
PLATFORM_HAS_BSD_TIME
PLATFORM_HAS_BSD_THREAD_CPUTIME
PLATFORM_HAS_BSD_SOCKETS
PLATFORM_HAS_BSD_IPV6_SOCKETS
PLATFORM_HAS_BSD_SOCKET_FEATURE_IOCTL
PLATFORM_HAS_BSD_SOCKET_FEATURE_SELECT
PLATFORM_HAS_BSD_SOCKET_FEATURE_GETHOSTNAME
PLATFORM_SUPPORTS_UDP_MULTICAST_GROUP
PLATFORM_USE_PTHREADS
PLATFORM_MAX_FILEPATH_LENGTH_DEPRECATED
PLATFORM_SUPPORTS_TEXTURE_STREAMING
PLATFORM_SUPPORTS_VIRTUAL_TEXTURES
PLATFORM_SUPPORTS_VARIABLE_RATE_SHADING
PLATFORM_REQUIRES_FILESERVER
PLATFORM_SUPPORTS_MULTITHREADED_GC
PLATFORM_SUPPORTS_TBB
PLATFORM_USES_FIXED_RHI_CLASS
PLATFORM_HAS_TOUCH_MAIN_SCREEN
PLATFORM_SUPPORTS_STACK_SYMBOLS
PLATFORM_HAS_128BIT_ATOMICS
PLATFORM_USE_FULL_TASK_GRAPH
PLATFORM_HAS_FPlatformVirtualMemoryBlock
PLATFORM_USE_FULL_TASK_GRAPH
PLATFORM_IS_ANSI_MALLOC_THREADSAFE
PLATFORM_SUPPORTS_GPU_FRAMETIME_WITHOUT_MGPU
(...)
以下提供了平台属性、输出设备、堆栈遍历等相关的操作:
// 输出设备在大多数平台的通用实现
struct FGenericPlatformOutputDevices
{
static void SetupOutputDevices();
static FString GetAbsoluteLogFilename();
static FOutputDevice* GetLog();
static void GetPerChannelFileOverrides(TArray<FOutputDevice*>& OutputDevices);
static FOutputDevice* GetEventLog();
static FOutputDeviceError* GetError();
static FFeedbackContext* GetFeedbackContext();
protected:
static void ResetCachedAbsoluteFilename();
private:
static constexpr SIZE_T AbsoluteFileNameMaxLength = 1024;
static TCHAR CachedAbsoluteFilename[AbsoluteFileNameMaxLength];
static void OnLogFileOpened(const TCHAR* Pathname);
static FCriticalSection LogFilenameLock;
};
// 平台属性
struct FGenericPlatformProperties
{
static const char* GetPhysicsFormat();
static bool HasEditorOnlyData();
static const char* IniPlatformName();
static bool IsGameOnly();
static bool IsServerOnly();
static bool IsClientOnly();
static bool IsMonolithicBuild();
static bool IsProgram();
static bool IsLittleEndian();
static const char* PlatformName();
static bool RequiresCookedData();
static bool HasSecurePackageFormat();
static bool RequiresUserCredentials();
static bool SupportsBuildTarget( EBuildTargetType TargetType );
static bool SupportsAutoSDK();
static bool SupportsGrayscaleSRGB();
static bool SupportsMultipleGameInstances();
static bool SupportsWindowedMode();
static bool AllowsFramerateSmoothing();
static bool SupportsAudioStreaming();
static bool SupportsHighQualityLightmaps();
static bool SupportsLowQualityLightmaps();
static bool SupportsDistanceFieldShadows();
static bool SupportsDistanceFieldAO();
static bool SupportsTextureStreaming();
static bool SupportsMeshLODStreaming();
static bool SupportsMemoryMappedFiles();
static bool SupportsMemoryMappedAudio();
static bool SupportsMemoryMappedAnimation();
static int64 GetMemoryMappingAlignment();
static bool SupportsVirtualTextureStreaming();
static bool SupportsLumenGI();
static bool SupportsHardwareLZDecompression();
static bool HasFixedResolution();
static bool SupportsMinimize();
static bool SupportsQuit();
static bool AllowsCallStackDumpDuringAssert();
static const char* GetZlibReplacementFormat();
};
// 用于捕获加载pdb所需的所有模块信息。
struct FStackWalkModuleInfo
{
uint64 BaseOfImage;
uint32 ImageSize;
uint32 TimeDateStamp;
TCHAR ModuleName[32];
TCHAR ImageName[256];
TCHAR LoadedImageName[256];
uint32 PdbSig;
uint32 PdbAge;
struct
{
unsigned long Data1;
unsigned short Data2;
unsigned short Data3;
unsigned char Data4[8];
} PdbSig70;
};
// 与程序计数器相关的符号信息。ANSI版本。
struct FProgramCounterSymbolInfo final
{
enum
{
/** Length of the string used to store the symbol's names, including the trailing character. */
MAX_NAME_LENGTH = 1024,
};
ANSICHAR ModuleName[MAX_NAME_LENGTH];
ANSICHAR FunctionName[MAX_NAME_LENGTH];
ANSICHAR Filename[MAX_NAME_LENGTH];
int32 LineNumber;
int32 SymbolDisplacement;
uint64 OffsetInModule;
uint64 ProgramCounter;
};
// 程序计数器符号信息
struct FProgramCounterSymbolInfoEx
{
FString ModuleName;
FString FunctionName;
FString Filename;
uint32 LineNumber;
uint64 SymbolDisplacement;
uint64 OffsetInModule;
uint64 ProgramCounter;
};
// 堆栈遍历
struct FGenericPlatformStackWalk
{
typedef FGenericPlatformStackWalk Base;
struct EStackWalkFlags
{
enum
{
AccurateStackWalk = 0,
FastStackWalk = (1 << 0),
FlagsUsedWhenHandlingEnsure = (FastStackWalk)
};
};
static void Init();
static bool InitStackWalking()
static bool InitStackWalkingForProcess(const FProcHandle& Process);
static bool ProgramCounterToHumanReadableString( int32 CurrentCallDepth, uint64 ProgramCounter, ...);
static bool SymbolInfoToHumanReadableString( const FProgramCounterSymbolInfo& SymbolInfo, ... );
static bool SymbolInfoToHumanReadableStringEx( const FProgramCounterSymbolInfoEx& SymbolInfo, FString& out_HumanReadableString );
static void ProgramCounterToSymbolInfo( uint64 ProgramCounter, FProgramCounterSymbolInfo& out_SymbolInfo);
static void ProgramCounterToSymbolInfoEx( uint64 ProgramCounter, FProgramCounterSymbolInfoEx& out_SymbolInfo);
static uint32 CaptureStackBackTrace( uint64* BackTrace, uint32 MaxDepth, void* Context = nullptr );
static uint32 CaptureThreadStackBackTrace(uint64 ThreadId, uint64* BackTrace, uint32 MaxDepth, void* Context = nullptr);
static void StackWalkAndDump(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, ... );
static void StackWalkAndDump(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, ...);
static TArray<FProgramCounterSymbolInfo> GetStack(int32 IgnoreCount, int32 MaxDepth = 100, ...);
static void ThreadStackWalkAndDump(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, ...);
static void StackWalkAndDumpEx(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, ...);
static void StackWalkAndDumpEx(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, ...);
static int32 GetProcessModuleCount();
static int32 GetProcessModuleSignatures(FStackWalkModuleInfo *ModuleSignatures, const int32 ModuleSignaturesSize);
static TMap<FName, FString> GetSymbolMetaData();
protected:
static bool WantsDetailedCallstacksInNonMonolithicBuilds();
};
19.12 本篇总结
本篇主要阐述了计算机硬件体系的由底向上的只是,以及UE对硬件层的抽象和封装,使得读者对此模块有着大致的理解,至于更多技术细节和原理,需要读者自己去研读UE源码发掘。