几十年前,计算机体系结构(computer architecture)一词仅指代指令集设计。计算机设计的其他方面称为实现,隐含之意通常就是实现方式不太重要,或者说没有什么挑战性。
计算机的实现包括两个方面:组成和硬件。
组成(organization)一词包含了计算机设计的高阶内容,比如存储器系统、存储器互连、内部处理器或CPU(算术、逻辑、分支和数据输送功能都在这里实现)的设计。有时也使用微体系结构(microarchitecture)一词来代替“组成”。
硬件是指计算机的具体实现,包括计算机的详尽逻辑设计和封装技术。同一系列的计算机通常具有相同的指令集体系结构和非常相似的组成,但在具体硬件实现方面有所不同。
现如今,体系结构涵盖了计算机设计的所有3个方面:指令集体系结构、组成或微体系结构、硬件。
要为计算机的发展做长远计划,设计人员必须了解实现技术的快速变化。以下5种实现技术是现代计算机实现所不可或缺的,它们都在快速变化:
- 集成电路逻辑技术
- 半导体DRAM
- 半导体闪存
- 磁盘技术
- 网络技术
带宽的增加速度至少是延迟改进速度的平方。在过去几年里,除了功耗限制之外,连线延迟已经成为大型集成电路的主要设计障碍,而且往往比晶体管开关延迟还要关键。
ILP主要有两种实现方法:(1)依靠硬件来动态发现并实现并行;(2)依靠软件技术在编译时静态发现并行。
为了利用指令级并行,就必须判断哪些指令可以并行执行:如果两条指令是并行的,只要流水线有足够的资源(不存在任何结构冒险),就可以在一个任意深度的流水线中同时执行它们,并且不会导致任何停顿。
相反,如果两条指令是相互依赖的,它们就不是并行的,必须按顺序执行,尽管它们通常是部分重叠的。
共有3种类型的依赖:数据依赖(也称为真数据依赖)、名称依赖和控制依赖。其中,数据依赖和名称依赖统称为数据依赖。
- 指令i生成的结果可能会被指令j用到
- 数据依赖的传递:指令j数据依赖于指令k,指令k数据依赖于指令i
当两条指令使用相同的寄存器或存储地址(称为名称),但与该名称相关的指令之间并没有数据流动时,我们称它为名称依赖。
现假设有一条指令i和一条指令j(指令i位于指令j之前),它们之间可能存在两种名称依赖:
- 反依赖:指令j对指令i读取的名称执行写操作
- 输出依赖:指令i和指令j对同一个名称执行写操作
只要指令间存在数据依赖或名称依赖,且它们非常接近以至于执行期间的重叠能改变对应依赖中操作数的访问顺序,就会存在冒险。
根据前面的定义,可能的数据冒险也有三种:
- RAW(写后读):由真数据依赖造成
- WAW(写后写):由输出依赖造成
- WAR(读后写):由反依赖造成
控制依赖决定了指令i相对于分支指令的顺序,使指令i按正确的程序顺序执行且只会在应当执行时执行。
基本流水线调度和循环展开
当流水线变得越来越深,而且分支的潜在代价增加时,仅使用延迟分支及类似机制就不够了,需要寻求一种更积极的方式来预测分支。
这些机制分为两类:依赖编译时可用信息的低成本静态机制;根据程序行为对分支进行动态预测的策略。
固定方向预测(总是分支/不分支)
基于方向的预测
基于程序语义的预测
基于统计分析的预测
最简单的动态分支预测机制是分支预测缓冲区或分支历史表(BHT,Branch History Table)。
分支预测缓冲区是一个小型存储器,根据分支指令地址的低位部分进行索引。这个存储器中包含一个预测位,表示该分支最近是否曾被选中。这种机制是最简单的缓冲区形式;它没有标志,仅当分支延迟过长,超过可能目标PC计算所需要的时间时,用于缩短分支延迟。
这个缓冲区实际上就是一个高速缓存,所有访问都会命中。缓冲区的性能取决于亮点:对所关注分支的预测频繁程度,以及该预测在匹配时的准确率。
这种简单的1位预测机制在性能上有一处短板:即使某个分支几乎总被选中,但在其未被选中时,由于错误预测会导致该预测位反转,因此我们也可能会得到两次错误预测(选中→不选中→选中)。
为了弥补这一缺点,经常使用2位预测机制。即预测必须错误两次之后才会进行修改。这就是比较简单的2位预测器,研究证明,2位预测器的效果几乎与n位预测器相同,所以大多数系统采用2位预测器,而不是更具一般性的n位预测器。
2位预测器方案仅使用单个分支的最近行为来预测该分支的未来行为。如果不仅仅考察要预测分支的历史信息,还查看其他分支最近的行为,就有可能提高预测准确率。
利用其他分支的行为来进行预测的分支预测器称为相关预测器(correlating predictor)或两级预测器(two-level predictor)。
一般情况下,(m,n)预测器利用最近m个分支的行为在$2^m$个分支预测器中进行选择,其中每个分支预测器都是单个分支的n位预测器。这种相关分支预测器的吸引力在于它的预测率高于2位预测器,而需要添加的硬件很少。具体而言,最近m个分支的全局历史可以记录在m位的移位寄存器中,其中每一位记录是否执行了该分支转移;将分支地址的低位与m位全局历史地址拼接在一起,就可以对分支预测缓冲区进行寻址,寻址到的就是单个n位预测器。
相关预测器最著名的例子可能就是McFarling的gshare预测器。在gshare预测器中,索引是通过利用一个异或操作将分支的地址低位和最近的条件分支结果结合在一起形成的。这种将局部分支信息和全局分支历史结合在一起的预测器也被称为融合预测器(alloyed predictor)或混合预测器(hybrid predictor)。
采用相关分支预测器主要是因为观察到:仅使用局部信息的标准2位预测器无法预测某些重要分支,而添加全局历史可能有助于改善这种情况。
竞争预测器(tournament predictor)更进一步,它采用了多个预测器(通常是一个全局预测器和一个局部预测器),并使用选择器在它们之间进行选择。竞争预测器是另一种形式的混合预测器或融合预测器。
全局预测器(global predictor)使用最近的分支历史作为预测器的索引,而局部预测器(local predictor)和选择器使用分支地址作为索引。选择器相当于一个2位预测器,当一个分支地址发生两次错误预测时,改变该分支地址所使用的预测器。
竞争预测器的优势在于它能够为特定分支选择正确的预测器,这一点对于整数基准测试尤为重要。
采用一系列以不同长度历史进行索引的全局预测器。又称为TAGE(TAgged GEmoetic)。
用于存储分支之后下一条指令的预测地址的分支预测缓存,称为分支目标缓冲区(branch-target buffer)或分支目标缓存(branch-target cache)。
动态调度:由硬件重新安排指令的执行顺序(乱序执行)以减少停顿,同时保持数据流和异常行为。
动态调度有如下三个优点:
- 允许针对一种流水线编译的代码在不同类型的流水线上高效执行,无须为不同的微体系结构重新进行编译。
- 可以应对编译时依赖关系未知的情况
- 允许处理器容忍一些预料之外的延迟,比如缓存缺失,它可以在等待解决缺失问题时执行其他代码
在经典的五级流水线中,可以在指令译码期间检查结构冒险和数据冒险:当一个指令可以无冒险地执行时,它就会从ID发射出去,并确认所有数据冒险都已解决。即,发射过程分为两个部分:检查所有结构冒险和等待数据冒险的消失。
IBM360/91浮点单元采用一种高级方案来支持乱序执行,这一方案由Robert Tomasulo发明。
它会跟踪指令的操作数何时可用,以将RAW冒险降至最低,还会在硬件中引入寄存器重命名功能,以将WAW冒险和WAR冒险降至最低。
两个关键原则:
- 动态确定一条指令何时可以执行
- 重命名寄存器以避免不必要的冒险(名称依赖)
克服的对应数据依赖如下:
- 数据依赖:仅在操作数可用时才执行该指令,就可以避免RAW冒险,而这正是一些简单记分牌方法提供的功能
- 名称依赖:WAR冒险和WAW冒险可以通过寄存器重命名来消除
在Tomasulo方案中,寄存器重命名功能由保留站提供,保留站会为等待发射的指令缓存操作数,并且与功能单元相关。
其基本思想是:保留站在一个操作数可用时马上提取并缓冲它,这样就不再需要从寄存器中获取该操作数。此外,等待执行的指令会指定保留站为自己提供输入。最后,在对寄存器连续进行写操作并且重叠执行时,实际只会使用最后一个操作更新寄存器。在发射指令时,会重命名待用操作数的寄存器说明符,改为提供寄存器重命名功能的保留站的名字。
由于保留站的数目可能多于实际的寄存器,所以这一技术甚至可以完全消除因为名称依赖而导致的冒险,这类冒险是编译器无法完全消除的。
使用保留站而不是集中式寄存器堆,还有另外两个重要特性:
- 冒险检测和执行控制是分布式的:每个功能单元保留站中保存的信息,决定了一条指令什么时候可以开始在该单元中执行
- 结果将直接从缓存它们的保留站中传递给功能单元,而不需要经过寄存器。这一旁路是使用公共结果总线完成的,它允许同时载入所有等待一个操作数的单元,在具有多个执行单元并且每个时钟周期发射多条指令的流水线中,将需要不止一条结果总线
在Tomasulo算法中,一条指令所经历的步骤有3个:
- 发射:从指令队列的头部获取下一条指令
- 执行:如果还有一个或多个操作数不可用,则在等待计算的同时监视公共数据总线。当一个操作数变为可用时,就将它放到任何一个正在等待它的保留站中。当所有操作数都可用时,则可以在相应功能单元中执行运算。
- 写回:在计算出结果之后,将其写到CDB(Common Data Bus)上,再从CDB传送给寄存器和所有等待这一结果的保留站(包括存储缓冲区)。存储指令一直缓存在存储缓冲区中,直到待存储值和存储地址可用为止,然后在有空闲存储单元时,立即写入结果
每个保留站有以下7个字段:
- Op:对源操作数S1和S2执行的运算
- Qj、Qk:将生成对应源操作数的保留站;当取值为0时,表面已经可以在Vj或Vk中获得源操作数,或者不需要源操作数
- Vj、Vk:源操作数的值
- A:用于保存为载入指令或存储指令而计算存储器地址所需的信息。在开始时,指令的立即数字段存储在这里;在计算地址之后,有效地址存储在这里
- Busy:表明这个保留站及其相关功能单元已被占用
寄存器堆有一个字段Qi(每个寄存器一个):
- Qi:如果一个运算的结果应当存储在这个寄存器中,则Qi是包含此运算的保留站的编号。如果Qi的值为空(或0),则当前没有活动指令正在计算应当存储在这个寄存器中的结果,也就是说寄存器的内容将保持不变。
基于硬件的推测结合了以下3种关键思想:
- 用动态分支预测选择要执行哪些指令;
- 利用推测在解决控制依赖问题之前执行指令(能够撤销错误推测指令序列的影响)
- 进行动态调度,以应对不同组合方式的基本模块调度(与之相对,没有推测的动态调度只能部分重叠基本模块,因为它要求先解析一个分支,然后才能实际执行后续基本模块中的任何指令)
基于硬件的推测根据预测的数据值流来选择何时执行指令,这种执行程序的方法实际上是一种数据流执行:操作数一旦可用就立即执行运算。
为了扩展Tomasulo算法以支持推测,我们必须将指令结果的旁路(以推测方式执行指令时需要这一操作)从一条指令的实际完成操作中分离出来。进行这种分离之后,就可以允许执行一条指令,并将其结果旁路给其他指令,但不允许这条指令执行任何不能撤销的更新操作,直到确认这条指令不再具有不确定性为止。
使用旁路值类似于执行一次推测寄存器读操作,因为在提供源寄存器值的指令不再具有不确定性之前,我们无法知道它是否提供了正确的值。当一条指令不再具有不确定性时,我们允许它更新寄存器堆或存储器;我们将指令执行序列中的这个附加步骤称为指令提交(instruction commit)。
实现推测背后的关机键思想是允许指令乱序执行,但强制它们顺序提交,并防止在指令提交之前采取任何不可撤销的动作(比如更新状态或引发异常)。因此,当我们添加推测时,需要将执行完成的过程与指令提交分隔开来,这是因为指令执行完毕的时间可能远远早于它们可以提交的时间。
要想在指令执行序列中添加这一提交阶段,需要增加一组硬件缓冲区,用来保存已经完成执行但还没有提交的指令结果。这一硬件缓冲区称为重排序缓冲区(Reorder Buffer),也可用于在可被推测的指令之间传送结果。
重排序缓冲区(ROB)像Tomasulo算法通过保留站扩展寄存器集一样,提供了附加寄存器。ROB会在一定时间内保存指令的结果,这段时间从与该指令相关的运算完成开始,到该指令提交完毕为止。因此,ROB也是指令的操作数来源,就像Tomasulo算法中的保留站一样。两者的关键区别在于:在Tomasulo算法中,一旦一条指令写出其结果,任何后续发射的指令都会在寄存器堆中找到该结果。而在采用推测时,寄存器堆要等到指令提交之后才会更新。
因此,ROB是在指令执行完毕到指令提交这段时间内提供操作数。同时,ROB类似于Tomasulo算法中的存储缓冲区,为简单起见,我们将存储缓冲区的功能集成到ROB中。
由于每条指令在提交之前都在ROB中拥有一个位置,所以我们使用ROB条目编号而不是保留站编号来标记结果。即保留站的重命名功能由ROB代替,但在发射运算之后仍然需要一个空间来缓冲它们(以及操作数),直到它们开始执行为止,这一功能仍然由保留站提供。
ROB中的每个项目包含4个字段:
- 指令类型:分支(没有目的地结果)、存储指令(含有存储器地址目的地)及寄存器操作(ALU运算或载入指令,含有寄存器目的地)
- 目的地字段:提供应当向其中写入指令结果的寄存器编号(对于载入指令和ALU运算)或存储器地址(对于存储指令)
- 值字段:在提交指令之前保存指令结果
- 就绪字段:表明指令已经完成执行,结果值准备就绪
- 发射:从指令队列获得一条指令。如果存在空闲保留站而且ROB中有空插槽,则发射该指令;如果寄存器或ROB中已经含有这些操作数,则将其发送到保留站。
- 执行:如果还有一个或多个操作数不可用,则在等待寄存器值被计算的同时监视CDB,这一步骤检查RAW冒险。当保留站中拥有这两个操作数时,执行该运算
- 写回:当结果可用时,将它写在CDB上(以及ROB标签),并从CDB写到ROB以及任何等待这一结果的保留站;将当前保留站标记为可用
- 提交:根据要提交的指令是预测错误的分支指令、存储指令,还是任意其他指令(正常提交),在提交时共有3种操作序列:
- 当一个指令到达ROB的头部而且其结果出现在缓冲区中时,进行正常提交;此时,处理器用结果更新其寄存器,并从ROB中清除该指令
- 提交存储指令与正常提交类似,但更新的是存储器而不是结果寄存器
- 当分支指令到达ROB的头部且预测错误时,它指出推测是错误的,ROB被清空,执行过程从该分支的正确后续指令重新开始。如果对该分支的预测正确,则该分支完成提交
多发射处理器主要有以下3类:
- 静态调度超标量处理器
- VLIW(超长指令字)处理器
- 动态调度超标量处理器
| 名称 | 发射结构 | 冒险检测 | 调度 | 显著特征 | 示例 |
|---|---|---|---|---|---|
| 超标量(静态) | 动态 | 硬件 | 静态 | 顺序执行 | 大多属于嵌入式领域:MIPS和ARM,包括Cortex-A53 |
| 超标量(动态) | 动态 | 硬件 | 动态 | 一些乱序执行,但没有推测 | 目前没有 |
| 超标量(推测) | 动态 | 硬件 | 带有推测的动态 | 具有推测的乱序执行 | Intel Core i3、i5、i7等 |
| VLIW/LIW | 静态 | 以软件为主 | 静态 | 所有冒险由编译器判断和指出(经常是隐式的) | 大多属于信号处理领域 |
| EPIC | 以静态为主 | 以软件为主 | 大多为静态 | 所有冒险由编译器隐式判断和指出 | Itanium |
VLIW使用多个独立的功能单元。VLIW没有尝试向这些单元发射多条独立指令,而是将多个操作包装在一个非常长的指令中,或者要求发射包中的指令满足同样的约束条件。
但这两种方法之间没有本质性的区别,所以假定将多个操作放在一条指令中,就像原始VLIW方法一样。
原始VLIW模块中既存在技术问题也存在逻辑问题,从而降低了该方法的效率。
技术问题包括代码大小的增加和锁步(lockstep)操作的局限性。
代码膨胀: 有两个因素共同造成了VLIW代码体量的增大:第一,要在直行代码段中生成足够多的操作,需要大量展开循环,从而增大了代码大小;第二,只要指令未被填满,那些没有用到的功能单元就会在指令编码时变为多余的比特位。
锁步: 早期的VLIW是锁步工作的,根本就没有冒险检测硬件。在这种结构中,由于所有功能单元都必须保持同步,所以任意功能单元流水线中的停顿都必然导致整个处理器停顿。
VLIW的一个主要逻辑问题是二进制代码的兼容性。在严格的VLIW方法中,代码序列既利用指令集定义,又要利用具体的流水线结构,包括功能单元及其延迟。因此,当功能单元数量和单元延迟不同时,就需要不同的代码版本。但在领域专用体系结构(DSA)中,这个问题并不严重,因为应用程序是专门为体系结构配置编写的。
所有多发射处理器都要面对的重要挑战是尝试利用大量ILP。
当并行是通过展开浮点程序中的简单循环而实现时,原来的循环很可能可以在向量处理器上高效地运行,对于这一类应用程序,多发射处理器是否优于向量处理器尚不清楚;其成本是类似的,向量处理器的速度可能与多发射处理器相同或者更快。
与向量处理器相比,多发射处理器的潜在优势在于,它们能够从结构化程度较低的代码中提取一些并行性,并且能够轻松地缓存所有形式的数据。
由于这些原因,多发射方法已经成为利用指令级并行的主要方法,而向量已成为这些处理器的扩展。
将动态调度、多发射和推测这3种技术结合在一起,就得到了一种非常类似于现代微处理器的微体系结构。
在动态调度处理器(无论有无推测功能)中,每个时钟周期发射多条指令是非常复杂的,原因很简单,即这些指令之间可能互相依赖。因此,必须为这些并行指令更新控制表;否则这些控制表中可能会出现错误,或者会丢失依赖。
在动态调度的处理器中,有两种方法被用来在每个时钟周期内发射多条指令。这两种方法都基于这样一个事实:要在每个时钟周期中发射多条指令,关键是分配一个保留站和更新流水线控制表。
- 第一种方法是在半个时钟周期内运行这一步骤,以便在一个时钟周期内运行2条指令;但很难将这一方法扩展为每个时钟周期处理4条指令
- 第二种方法是构建必要的逻辑,一次处理两条或更多条指令,包括指令之间可能存在的依赖关系。
在每个时钟周期发射4条或更多条指令的现代超标量处理器,可能两种方法都采用:既采用流水线方式,又拓宽发射逻辑。一个重要的事实是:仅靠流水线无法解决这一问题。由于每个时钟周期都会发射新指令,所以通过让指令发射占用多个时钟周期,必须能够分配保留站,并更新流水线表,使下一个时钟周期发射的相关指令能够利用更新后的信息。