这篇文章是 Processor Microarchitecture An Implementation Perspective 的读书笔记。虽然这本书是 2011 年出版的,讲的东西都已经有点过时了,但是作为一个入门 CPU 微架构的材料还是不错的。对于程序员来说了解 CPU 的工作原理也有助于写出更高性能的代码,充分发挥硬件性能。
在大学本科里学习的计算机组成原理一般是顺序执行标量五级流水线的 CPU,但是在现代的高性能 CPU 中,在一个时钟周期里不同的执行部件可以有多条指令同时执行(例如同时执行两个整数加法)。另外,CPU 也有可能乱序执行程序的指令(在没有数据依赖的情况下)。而在不同的执行阶段中,还可能划分更小的流水线(例如浮点数操作)。所以,现在的 CPU 的流水线可能不止五层,而且是执行模型乱序多发射的。当然,在一些对性能要求不高,而对能耗和芯片面积比较敏感的平台上,CPU 的设计则可能会简化为三级流水线。
首先介绍一下 CPU 的分类维度,第一个是是否采用了流水线设计;第二个是乱序还是顺序执行,在乱序执行中,指令可以不按程序指定的顺序执行,减少阻塞,但对外表现的行为还是和顺序执行的处理器一样;第三个是标量和超标量处理器,标量处理器的 IPC 最多为 1,因为只有一套执行单元,而不是标量处理器的就是超标量处理器,IPC 可以大于 1,例如 VLIW 处理器;第四个是向量处理器,向量处理器可以使用一条向量指令处理多个元素的向量,也就是 SIMD,例如 Intel 的 AVX 指令就是 SIMD 指令;第五个是是否多核;第六个是是否多线程,多线程和多核的区别是,多核处理器中每一个核心的硬件资源相对独立不共享,而多线程中的线程通常共用大部分的硬件资源,比如 Intel 的超线程就是多线程,一个核心上有两个线程。
大体上讲,CPU 的流水线是这样工作的。首先,取指模块从内存中获取下一条需要执行的指令,然后译码模块将指令解码为相应的控制指令。在重命名阶段,CPU 将给需要执行的指令分配寄存器,并将指令分配到一条发射队列中。在发射阶段,CPU 检查发射队列中的指令的操作数是否已经准备好了(例如已经从内存中读取了数据),将已经就绪的指令发送到执行单元执行。执行完毕之后,则需要将操作数写回寄存器,在乱序 CPU 中,则是写回重排缓冲区(ROB)。最后,当一条指令可以提交后,就真正修改 CPU 的内部状态,完成一条指令。
缓存
现在 CPU 的大多数访存操作都需要通过缓存进行(有一些 CPU 支持绕过缓存的指令),所以,有必要了解缓存的设计原理。
目前的 CPU 都通过虚拟地址来访问内存,在访问前需要进行地址转换。虚拟地址转换为物理地址的映射关系也是存储在内存中的,但由于地址转换是非常频繁的操作,所以 CPU 中有 TLB 缓存用来加速地址转换。TLB 使用虚拟地址索引,里面存放的都是页表项。
得到物理地址之后,就可以向数据缓存发起访问请求了。缓存一般是截取物理地址的一部分作为缓存行索引。在缓存中,一个缓存行索引可以对应一路或多路缓存行,具体数量称为关联度。一个索引对应一路缓存行的,称为直接映射;一个索引对应整个缓存的称为全相连;剩下的就称为 n 路组相连。
由于不同地址可能有相同的缓存行索引,所以地址除了页内偏移量以及缓存行索引的剩下的比特作为缓存行的标签,用来确定缓存是否有效。缓存的标签和数据访问可以并行执行,也可以先比较完标签再访问数据。
由于缓存处于关键路径上,为了满足现在 CPU 的高频率要求,缓存控制器本身也是流水线化的。在第一阶段,先计算出索引和标签位等,然后在第二阶段进行消歧义操作,第三阶段访问缓存,第四阶段根据偏移量从缓存行中取出相应的数据。
缓存也可以设计为先比较标签再访问数据。这样做虽然引入了额外的一个时钟周期的延迟,但是由于比较标签后可以只访问命中的那一路数据,可以减少能耗,同时缩短了关键路径,进一步提高时钟频率。
可以看出序列比较标签的缓存的流水线多了一级。对于乱序执行处理器来说,乱序执行可以掩盖多出来的一个周期,所以使用序列比较的缓存有利于频率提升。顺序处理器使用并行比较标签更为合理。
关联度越大的缓存产生冲突的几率越小,但是占用芯片面积更大,电路复杂,能耗更高,所以一般的缓存关联度不会太大,例如常见的有 12 路、16 路等。
缓存在未命中的时候需要访问下一级存储获取数据,简单的做法是阻塞这条指令,直到缓存获取到数据。但是这样对性能损失很大。在乱序处理器中,缓存允许在一条指令还没有获取到数据的时候,就执行其他不依赖这个数据的指令。另外,还有一个要求就是非阻塞,或者称为无锁的。无锁的缓存即使在有未命中的指令执行的时候,也允许处理器发射新的访存指令。为了跟踪还没有完成的访存请求,缓存中使用了未命中状态保持寄存器(MSHR)。另外,数据到达缓存的时候,会进入输入栈(现代 CPU 称为填充缓冲区 Fill Buffer),然后才写入到数据矩阵中。
对于无锁缓存来说,未命中的情况可以分为三类:
- 初级未命中:发生的第一次未命中,此时缓存会向下一层存储请求数据。
- 次级未命中:还在请求数据的缓存块再一次发生未命中的情况。
- 结构阻塞未命中:由于硬件资源不足而无法处理的次级未命中。这种未命中会因为结构冒险而导致阻塞。
MSHR 也有很多种组织方法。
上图是隐式寻址 MSHR,如果一个缓存块包含 N 个字,那么就有 N 个目的寄存器,并且还有一个寄存器保存块地址。发生初级未命中的时候,会设置这个地址寄存器以及相应字的目的寄存器。如果后续发生了次级未命中,就比较地址,并设置目的寄存器。MSHR 里保存了一个缓存块里的一个字的目的寄存器,以及一些格式信息,例如此次访存指令的位宽,是否需要符号扩展等。也就是说一个访存指令在块内的地址隐式地由目的寄存器的编号决定。
相对地就有显式寻址 MSHR。这种 MSHR 不要求目的寄存器的数量和块的大小相同,但是需要显式地在寄存器中编码一次访存请求的块内偏移。这样可以允许任意数量的未完成请求,和缓存块大小解耦。
还有一种缓存内 MSHR。因为在缓存块还没有读取完毕的时候缓存块是没有数据的,所以可以利用这个空间来保存 MSHR 信息,也不必使用额外的地址寄存器。这种设计要求缓存块额外带一个 transient 标记位,用来标记这个缓存块是不是还在获取。
现代 CPU 通常支持一个周期内发射 2 条访存指令,为了支持 CPU 的带宽需求,缓存需要提供多个读写端口。
一种简单的设计是,缓存真的提供两个读端口,也就是每个数据块都连接到两个读端口上。但是这样能耗高、延迟高、占用面积大,没有 CPU 采用这样的设计。
另一种方法是将标签和数据块复制两次,每个数据块只连接到一个读端口,这样可以降低延迟,但是在写入的时候需要同时写入两个数据块。
现代的 CPU 通常采用多 Bank 的设计,在一个周期内,不同 Bank 的请求可以并发执行,而同一个 Bank 的则会冲突,需要阻塞。每个 Bank 内都有独立的标签和数据矩阵,比较器等。这体现了分而治之的思想。
对于指令缓存来说,一般使用的是阻塞的缓存。数据缓存使用非阻塞是因为处理器可以发射多条不依赖数据的指令来掩盖延迟,但是取指的时候,之后的指令按照程序顺序隐式依赖与前面的指令。所以指令缓存发生未命中的时候,需要等之前的指令获取完毕后才能执行后面的指令。所以指令缓存就算实现为非阻塞的也没有多大用处。
由于并行比较标签可以节省一个周期的延迟,所以指令缓存可能更喜欢用这个。但是这也会导致时钟频率下降和能耗上升,需要仔细权衡 trade-off。
取指
在现代 CPU 中,取指单元一个周期可以取一条指令,也就是说取指单元需要每个周期都计算出下一条指令的地址。然而在如果是分支指令,在真正执行分支指令之前是没有办法知道下一个执行的地址是什么的。如果让取指单元空等直到执行完毕的话,那么会浪费许多 CPU 周期,造成性能下降。所以,现在的 CPU 都有一个分支目标缓冲区(BTB),用来预测下一个指令地址。由于程序里许多跳转也涉及函数的返回,一些 CPU 还有返回地址栈(RAS)。
一条简单的取指流水线可能包含四个阶段,第一个阶段是不同的分支预测器预测下一个地址,第二个阶段是根据预测器的结果确定取指地址,第三阶段则是从指令缓存中取指,第四阶段是将指令从缓存行中取出并驱动译码单元。虽然这样取指需要四个周期,但由于是流水线化的,实际上可以做到一个周期取一条指令,并且能够提供较高的时钟频率。
译码
译码单元和 ISA 有很大的关系,RISC 指令集通常是定长的,而且操作数编码在相对固定的位置,译码单元比较简单,并行度也比较高。而像 x86 这种变长的 CISC 指令集,译码单元则相当复杂,并且译码单元还需要在指令字节流里面切割出不同的指令。由于 CISC 指令太过复杂,实际上在 CPU 内部,一条 CISC 指令通常也是进一步译码为多个类似 RISC 的微指令。P6 的微指令长度为 118 位,可以从大小看出这个相比于 RISC 指令(32 位)来说,更像是译码之后的 RISC 指令。
在 Intel Core CPU 中,取指单元的指令会先放在预取缓冲区,然后指令长度译码器负责分割出多个指令,送入指令队列。译码器从指令队列取出指令并将指令译码为微指令,送入指令译码队列。对于一些简单的指令,例如寄存器-寄存器指令,只需要译码为一个微指令,而一些复杂的指令则需要译码为至多 4 个微指令,这样设计的好处是在大多数指令都是简单指令的情况下,可以节省一部分能耗而不牺牲译码带宽。对于一些极其复杂的指令,例如字符串相关的指令,则会停止正常的译码流水线,并将控制转移到 MSROM。这个 ROM 无非也是一个序列发生器,生成指令对应的微码流。
分配
在这个阶段中,CPU 主要完成两个操作:寄存器重命名和资源分配。其中寄存器重命名是为了解决命名冲突(读后写,写后写),而资源分配则是预留发射队列、重排缓冲区项、Load/Store 队列等指令后续操作可能使用的硬件资源。如果资源不足,那么这条指令就需要等待到资源可用才可以分配。有时候不同的硬件功能单元(例如访存、浮点数、整数)会有各自独立的发射队列,在分配过程中,也有可能按照指令的类型分配到不同的发射队列中。
寄存器重命名一个出名的算法是 Tomasulo 算法,这个算法用执行单元来命名输出结果的寄存器,但有个缺点是它在指令执行完成之前都要占用保留站的资源。所以现在的处理器在指令发射之后就释放发射队列项,提高执行效率。
在汇编指令中的寄存器名称是逻辑寄存器,而因为寄存器重命名,一个逻辑寄存器可能在不同时候会对应不同的物理寄存器。而实现寄存器重命名有多种方法,例如使用重排缓冲区、使用重命名缓冲区以及使用合并寄存器堆的方法。
通过重排缓冲区重命名是一种重命名方法,指令执行的结果会写入到 ROB 中,在指令提交的时候,再将 ROB 中的值写入到寄存器堆相应的位置。使用这种方法的时候,一个逻辑寄存器的值可能会映射到 ROB 中,也可能是在寄存器堆中,在执行的时候需要判断究竟需要从哪里取指。
另一种方法是上面方法的一个小改进,也就是使用重命名缓冲区。由于许多指令其实不需要写寄存器,所以如果为这些指令分配重排缓冲区项的话会浪费一些资源。所以,在改进的方法中,只为那些需要写寄存器的指令分配一个重命名项,而在指令提交的时候同样将对应的值写入寄存器堆中即可。
上面两种方法的一个缺点就是一个值需要写入两次,一次写入到缓冲区,一次写入到寄存器堆,而合并寄存器堆则节省了一次写入,而且只需要从寄存器堆读,不需要判断是否在 ROB 中。这种方法的 CPU 内部有比逻辑寄存器数量多的物理寄存器,并且 CPU 会维护一个映射表,将逻辑寄存器映射到物理寄存器。另外,还有一个环形队列,用来维护当前空闲的寄存器列表。在一条指令需要写入结果的时候,从空闲寄存器堆中分配一个寄存器,没有空闲的寄存器则需要等待。
在指令提交的时候,只需要释放原来的物理寄存器,并更新映射表即可,不需要将值复制写入。
还有一个重要的问题需要解决的是指令在什么时候读寄存器。一种是在发射前读寄存器的值,并且在发射队列中的项包含了具体的值;另一种是在发射后再读寄存器,发射队列中的项只包含逻辑寄存器编号,这种方法只需要读一次,而且不需要将值一直复制下去。理论上读寄存器的方法和重命名方法是正交的,对于合并寄存器来说,两种读的方法没有什么区别,但是使用重排缓冲区的方法更适合在发射前读寄存器。这是因为如果一条指令在执行之前,它的一个操作数寄存器有指令提交写入了,那么提交的时候就需要找到所有使用这个寄存器的指令,并将其 ROB 指针修改为指向寄存器堆。这在硬件电路实现上非常麻烦,所以使用重排缓冲区或者重命名缓冲区的 CPU 一般都是在发射前读寄存器。
在发生分支预测错误或者异常的时候,已经分配了的指令需要重置,释放队列项等,并将修改的重命名表等恢复到原来的状态。
发射
在发射阶段,CPU 会检查发射队列中的指令的源操作数是否已经就绪,一旦操作数就绪,就会将指令发射到执行单元执行。一些 CPU 可能会给不同的执行单元分配不同的发射队列,而一些 CPU 所有的执行单元共用一条发射队列。正如前面所说,读取操作数有两种方法,一种是发射前读取,另一种是发射后读取。
发射前读取的发射单元中保存了指令所需的数据(data)和寄存器编号(id)。对于一些只使用一个寄存器的指令,指令对应的有效位会置零,而就绪位标识了一个操作数是否就绪,如果就绪的话,对应的数据项则保存了对应的值。
和发射单元相关的主要有三个事件,一是发射队列分配,二是指令唤醒,三是指令选择。
在分配阶段,分配单元会尝试在发射队列分配一个发射队列项,然后进行寄存器重命名操作。然后,分配单元读取寄存器和映射表,对于已经就绪的寄存器,直接读取值,没有就绪的就保留寄存器号,最后,分配单元将指令以及相关的信息写入发射队列中。
在一个指令执行完成的时候,会发送一个唤醒信号,包含重命名之后的寄存器 ID 以及相应的值。发射单元此时根据信号匹配发射队列中的指令的源操作数寄存器 ID,并将匹配的项的就绪位设置为 1。一旦一条指令的所有有效操作数都就绪了(指令被唤醒了),那么这个指令就可以考虑被选择单元选择发射执行了。需要注意的是信号只会发送一次,如果有指令还没进入发射单元的话将无法收到信号,此时需要修改寄存器映射表中的记录,将对应的寄存器设置为可用。另外,为了避免死锁,在分配器写入发射队列项之前,也需要检查是否有信号。
正如前面所说,唤醒信号是在一个指令产生了操作结果后发出的。如图所示,如果每个指令都等到执行完成之后才唤醒的话,那么就会造成唤醒前有三个周期在空等。为了进一步提高流水线的利用率,唤醒信号可以在指令选择阶段就发出,这样等到下一条指令准备开始执行的时候,上一条指令恰好执行完成,通过转发电路,可以直接获取操作结果,避免流水线气泡。如果指令选择和唤醒不能在同一个周期完成的话,就会带来很大的性能损失。
产生唤醒信号有多种办法。一种是在指令执行前三个周期的时候就发出信号,当然具体几个周期需要根据指令的类型确定,例如整数加法操作可能只需要一个周期,而整数乘法周期则需要多个周期完成。另一种办法是将就绪位实现为移位寄存器,在指令选择之后,根据指令的执行周期数,将移位寄存器对应位置的位设为 1,同时在每一个周期都进行移位操作。这样,在第 0 位被置为 1 的时候操作数就就绪了。需要注意的是这些方法只适合延迟已经确定的指令。对于访存指令,由于实际的延迟取决于是否命中 TLB、数据缓存等,需要等到地址计算完成并访存结束之后才能唤醒。一种优化是在缓存返回命中的时候就直接唤醒,在缓存未命中的时候则等到访存结束再唤醒。另一种优化则是预测执行,在缓存未命中的时候付出一定代价,加速缓存命中的执行速度。
指令选择单元检查一条指令的操作数是否就绪和执行单元是否空闲。例如,一个只有一个乘法器的 CPU 是无法同时执行两条乘法指令的。指令选择单元非常重要,正如前面所说,为了支持指令“背靠背”地执行,选择逻辑必须在一个周期内完成。为了提高性能,现在的 CPU 通常会实现不止一个指令选择器,而是将其拆分成多个仲裁器或调度器。例如,一个支持 4 发射的 CPU 可能会实现 2 个或 4 个仲裁器,不同的执行单元静态地绑定到一个仲裁器。指令重命名之后,就会根据指令类型发送到不同的仲裁器的发射队列中。当就绪的指令多于发射宽度的时候,调度器需要根据一定的算法确定不同指令的优先级。
当指令发射到执行单元开始执行之后,对应的发射队列项就已经可以安全地回收了。一个例外是如果这条指令是访存指令,并且处理器使用了预测唤醒的话,那么就要等到安全的唤醒之后才可以回收。
对于在发射后读取寄存器的设计来说,发射器就不需要保存寄存器的值了,但是在唤醒和执行之间多了一个读取寄存器的周期。另外,发射后读取需要寄存器堆有和发射宽度一样多的读端口,而发射前读取只需要寄存器堆有和机器宽度一样多的端口。有点反直觉的是发射宽度有时候会大于机器宽度。这是因为不同的发射队列绑定了不同的执行单元,一个 4 发射的整数执行单元和一个 4 发射的浮点数执行单元的发射宽度是 8。
由于读端口的数量会影响芯片面积以及功耗,所以在发射后读取的设计中需要想办法减少读端口的数量。Alpha 21264 的优化方法是将寄存器堆分为两个复制的堆,每个有减半数量的读端口。而处理器还可以大胆地使用比最坏情况更少的读端口。为了解决读端口不足的问题,调度器可以互相协商,如果发射的指令多于读端口,那么一些调度器就暂停发射指令。但是分布式的调度器本来就是为了降低延迟,采取这样主动协商的办法可能导致延迟上升。另一种被动的方法是调度器一律发射指令,在指令执行发现读端口不足的情况下,取消指令并重新发射。这种情况有可能导致活锁问题,所以需要调度器采用更复杂的调度算法。
以上的调度算法只适用于非访存操作的。访存操作的发射要更加复杂,在一个访存操作发射的时候,要确保它和其他的访存操作没有冲突。负责处理内存依赖的机制就叫做内存消歧义策略。不同的 CPU 可能会采用不同的策略,大概可以分为非预测和预测的消歧义策略。非预测的消歧义策略在确定一个内存操作不会和之前任何的内存操作产生依赖的时候才允许执行;而预测策略则是想办法预测一个内存操作是否和另一个正在执行的内存操作产生依赖。过于保守的策略会限制指令的并行度,而过于激进的策略则会导致恢复的机制非常复杂,预测失败的时候的能耗也会急剧上升。
非预测消歧义策略主要有全序、偏序、加载/存储序。全序中,所有内存操作都是按照顺序执行的,目前已经没有乱序处理器会使用这种办法,因为这样会大大减少并行度。剩下的策略允许加载操作相对于存储操作乱序执行。加载/存储序中,加载操作按照自己的顺序执行,存储操作按照自己的顺序执行,但是加载操作不需要等待之前的存储操作访存完毕。而在偏序中,加载操作可以乱序执行,只要之前的所有存储操作的内存地址已经计算完毕即可。
一旦存储操作的内存地址计算完毕,CPU 就可以进行内存消歧义了。所以一些 CPU 会将存储操作进一步划分为两个子任务,一个是计算地址,另一个是进行实际的写入操作。
在 AMD K6 处理器中,实现的是加载/存储序。
在访存流水线中,加载队列用于按照程序顺序存放加载指令。指令在重命名之后插入到这个队列,直到它的操作数就绪并且到达了队列头部。地址生成器则是计算访存操作的地址。存储队列和加载队列类似,按照程序顺序存放存储指令,也是在重命名之后插入,并且等待操作数和到达队列头部。存储缓冲区则按程序顺序存放了存储操作,当一个操作变成 CPU 中最旧的指令的时候才会更新内存。值得注意的是,存储操作不需要等存储数据就绪就可以发射,也就是存储缓冲中的存储指令还有可能需要等待存储数据执行完成才能写入内存。
加载指令会将自己的地址和存储缓冲中的比自己旧的指令的地址做比较,同时如果正在计算地址的存储指令更旧,也会和它的地址做部分比较。之所以是部分比较是因为在比较的时候地址还没能计算完毕,所以如果一部分的位相同就认为是相同。最后加载指令检查调度器看看是否还没有更旧的存储指令没有计算出地址。如果一条加载指令和任何之前的存储指令地址相同,或者发射队列还有更旧的存储指令,整个加载流水线就需要暂停。虽然看起来加载需要按序执行有点多余,但是这是一种实现 x86 内存语义的简单方法,也就是存储需要按顺序可见,加载需要看起来像是按序执行的。Intel Core 处理器中,这个一致性要求也实现了,但是它允许预测的内存消歧义,也允许加载乱序执行,甚至允许在有存储没有计算出地址的情况下执行。
对于预测内存消歧义来说,加载指令不需要等待之前的存储指令计算出地址,但是需要特殊的硬件来识别出错误的预测并恢复执行。
图中可以看出多了一个等待表,内容是使用虚拟地址索引的 1024 个比特。当一个加载依赖于一个更早的存储指令的时候,会触发存储-加载陷阱,并相应地更新等待表,将加载指令地址相应的位置位。为了避免等待表里全部都是 1,每 16384 个周期等待表就会重置一次。
加载指令将计算好的地址写入到加载队列中,并且比较更新的加载指令的地址,如果有相同的,会触发加载-加载内存违例陷阱,这个陷阱会使得处理器从触发陷阱的指令处开始执行,如果没有需要触发陷阱,那么加载指令就继续访问内存和缓存。
在内存消歧义阶段,存储指令同样将自己的地址写入到存储队列。另外,它们还会检查是否有更新的加载指令的地址和自己的相同,如果有,就会触发存储-加载违例陷阱,并继续从加载指令开始执行。另外等待表也会更新,避免这种情况再次发生。需要注意的是没有存储-存储违例,因为存储只有在指令提交才生效,而指令提交本身就是按程序顺序的。
对于加载指令的数据消费者而言,一种保守的唤醒策略是在计算出缓存是否命中之后才发出唤醒的信号,这样会引入流水线气泡;另一种策略是预测唤醒,不管是否命中都发出唤醒信号,避免气泡。但是在缓存未命中的情况下,需要取消依赖指令的执行,并重新放入发射队列。如果发射队列这时候没有空闲位置,并且队列里的指令都依赖于这个被取消的指令,那么就会发生死锁。
死锁的解决办法也有多种,各有各的优劣势,一种办法是直接清空比被取消的指令更新的所有指令,然后重新开始执行。这种方法在预测错误很多的时候会造成性能的急剧下降。另一种方法是在确定一条指令不会被重新发射之前,不要清空对应的发射队列项。实现方法是每一个队列项都有一个发射位标记这个指令是否已经发射,如果已经发射了那指令选择逻辑就不会考虑这条指令。这种方法的性能损失比前面那种方法要少,但是需要很深的发射队列,但通常发射队列不会很长,如果队列里全是已发射而未完成的指令,同样会造成性能下降。因此,一些处理器选择另外实现一条重放队列,已经发射但还没有执行完毕的指令会先进入重放队列,在需要重发射的时候,调度器则给重放队列里的指令更高的优先级。
执行
在执行阶段,指令结果被真正地计算出来。不同的指令有不同的复杂度,因此也有不同的延迟。现在的 CPU 一般会有多个不同功能的功能单元。例如常见的有 FPU 浮点计算单元,ALU 算术逻辑单元,AGU 地址生成单元以及 BRU 分支计算单元。数据缓存是访存操作中的重要组成部分,同时访存操作还包括了地址转换单元,将虚拟地址转换为物理地址。另一个重要的结构是旁路网络,它将源数据和计算结果在不同的功能单元传递。另外,如果一些有依赖的指令想背靠背地执行,也需要旁路网络提前转发操作结果。
ALU 执行的是简单的整数操作,例如加法、减法、位操作,而整数乘除法因为太过复杂,通常由单独的功能单元执行(IMUL、IDIV)。另外,一些处理器为了节省芯片面积和能耗,会利用浮点数单元来计算整数乘除法,也就是先将整数转换为浮点数之后进行运算,最后再转换为整数。
内存地址空间模型有线性模型和分段模型,线性模型对于程序来说内存就是一整段连续的内存地址,而分段模型就是将内存分为不同的段,在段内使用偏移量访问。x86 的分段模型是最复杂的地址模型之一,它的 AGU 输入有基址、偏移量、尺度和索引四个部分。
最终的地址为 Offset = Base + (Index × Scale) + Displacement,另外 AGU 还需要检查地址是否越界。由于计算过程太复杂,可能无法在高时钟频率的条件下计算出来,所以 AGU 还可能分为多级流水线。
另一种办法是将访存操作拆分为更多的微码,但是这样会牺牲一定的发生宽度,而且访存指令就不能使用简单译码器了。
分支单元计算的是下一个需要执行的指令地址:
一般分支有三个寻址模式:直接绝对寻址、直接相对寻址以及间接寻址。直接绝对寻址的指令直接包含了下一个 PC 的值,直接相对寻址则包含了相对当前 PC 的偏移量,间接寻址则指定一个存放了下一个 PC 的寄存器。
浮点计算单元一般比较复杂,占用芯片面积很大,内部也是有多级流水线的。浮点寄存器和通用寄存器通常是分开的寄存器堆。FPU 通常除了支持乘除法以外,还可以支持三角函数运算,求平方根等。x86 除了支持 IEEE 754 的 32 位和 64 位浮点数以外,还支持 80 位的浮点数操作。
SIMD 计算单元通常用于向量计算。在最早的 Cray 超算里,SIMD 实现为向量处理器操作,支持上百个元素的向量。而 Intel CPU 的 SIMD 功能最早是为了支持游戏和多媒体应用,这些应用的向量不会很大,只有 4 个元素或者多一点。由于设计理念的不同,通常将元素较多的操作称为向量操作,而元素较少的称为 SIMD 操作。
SIMD 单元内部同样有浮点计算单元等,而且每一个单元内部还会进一步划分 Lane。一个 Lane 对应的就是一个元素的操作。Lane 的数量可能小于 SIMD 操作的宽度,在这种情况下,一个 SIMD 操作可能需要多于一个时钟周期完成。
假如一个 Lane 宽度是 64 位,SSE 操作的位宽是 128 位,那么如果有两个 Lane,一次 SSE 操作就可以在一个周期内完成(中图),否则需要两个周期(下图)。
如果等到写回阶段执行的时候才读取数据,会造成流水线气泡,流水线越深的时候气泡带来的性能损失越严重。为此,现代 CPU 都实现了旁路网络,等到计算结果一出来,就直接转发给消费者。
这种设计需要添加额外的电路和多路选择器。有一些处理器为了减少设计复杂度和提高时钟频率,也会选择不实现旁路网络,引入流水线气泡开销,并通过乱序执行填充气泡。
没有旁路网络的 CPU 中,功能单元的输出直接连接到寄存器堆的写端口,寄存器堆的读端口连接到功能单元输入。在简单的旁路网络中,输出除了连接到写端口,还直接和寄存器的读端口一起,经过多路选择器,连接到功能单元的输入端,形成了结果总线。
在比较深的流水线里面,数据可以从不同阶段的生产者转发到不同阶段的消费者。
而对于顺序处理器,旁路网络的设计可能会非常复杂。在顺序处理器中,写回阶段必须等到流水线中最慢的功能单元执行完毕之后才可以执行。对于这种延迟写入结果的操作,就称为 Staging,存放结果的寄存器成为 Staging 寄存器。
现在的处理器有很多功能单元,如果每一个单元都和其他的连接,就会形成极其复杂的旁路网络,影响时钟频率的提升。所以,一般浮点数单元和 SIMD 单元、整数单元有各自的旁路网络,减少网络复杂度。另外,AGU 一般和整数单元有关,它们也会有旁路网络连接。
当然,为了进一步降低复杂度,提升时钟频率,可以采用分而治之的原则,将不同的电路聚合在一起,不同的聚合体之间相互独立。
例如,我们可以允许一个功能单元只能旁路自己的操作结果,虽然会引入流水线气泡,但是可以降低电路复杂度,提高时钟频率,最终提高性能。这体现了系统设计中的 trade-off 考虑。
另一种思路是将寄存器堆分为两个,每个只有一半的端口(Alpha 21264),如果一个结果在其中一个寄存器堆产生,在另一个寄存器堆旁路使用,那么就需要额外的时钟周期复制一次。两个寄存器堆的内容是一样的。
更激进的做法是两个寄存器堆中的内容并不相同,执行结果只会写入局部的寄存器堆,而不会广播到另一个寄存器堆。需要使用的时候通过额外的复制机制使用。一个集群中的指令只会竞争自己集群内部的资源。
通常这种设计也会涉及到分布式的发射队列,依赖于分配阶段的指令分配机制来调度依赖的指令到同一个集群中执行。但是这对分配算法有很高的要求,需要尽可能平衡两个集群中的指令分配,不要出现一个集群很忙而另一个集群空闲的状态。
提交
为了让乱序执行的指令最终看起来像是顺序执行的,处理器在最后还需要一个提交阶段,用来强制指令以程序顺序修改最终的寄存器状态。一个 CPU 有两个状态:架构上的状态以及预测的状态。预测的状态就是架构状态加上还在执行的指令修改的状态。预测的状态并不保证最终会落实到架构状态上,因为分支预测有可能出错,也有可能在执行过程中发生异常。如果一个 CPU 可以在异常发生的时候,保证异常指令后的所有指令都不会真正修改架构状态,那么就说这个 CPU 可以提供精确的异常。
架构状态包含了每一个逻辑寄存器的状态加上内存的状态。所以,存储操作必须等到指令提交的时候才可以修改内存,而正在执行的加载指令需要检查是否在同一个地址上有更早的存储指令还没有提交。
在 Intel P6 中,使用的是 ROB 重排缓冲区来暂时保存预测状态,RRF 退休寄存器堆保存了架构状态。
在分配阶段的时候,从重排缓冲区分配一项,而在提交的时候释放重排缓冲区项,并更新架构寄存器。
提交的时候,需要通知指令队列和发射队列中的指令,指示它们需要从退休寄存器堆中读取值,而不是去重排缓冲区读。正如前面所说,所有还没执行或者读取寄存器的指令都需要检查这次通知,重命名表也需要更新。
基于重排缓冲区的乱序执行最好是在发射前读取,否则会变得很复杂。而基于合并寄存器堆的可以使用发射后读取的方式。但是这种办法相比重排缓冲区的方法,需要更复杂的管理机制。它需要一个额外的列表来保存可用的物理寄存器,而且在它能确定一个物理寄存器不再需要之前,都不能释放这个物理寄存器。一般来说,一条指令 A 写入的物理寄存器需要在另一条更新的指令 B 写入相同的逻辑寄存器后才可以释放。
对于预测执行出错的情况,需要清空流水线,并将 PC 重新设置为正确的值后开始执行。前端主要需要恢复分支预测表和 PC 寄存器,后端则需要恢复分配表、发射队列、重排缓冲区等等。对于 Intel Pentium 处理器来说,如果发生了预测错误,那么它就会等预测错误的那条分支指令以及这之前的所有指令都提交后,才开始恢复过程。恢复的过程只需要将所有的逻辑寄存器指向退休寄存器堆即可。
对于合并寄存器堆的 CPU 来说,一般不会等到分支指令提交之后才开始恢复过程。在执行的时候,CPU 会保存一个日志,记录重命名表的修改过程以及指令分配过的资源。需要恢复的时候,就根据日志恢复。日志项一般包含指令写入的逻辑寄存器以及分配给这条指令的物理寄存器或者分配给同一个逻辑寄存器的上一个物理寄存器编号。如果日志太长,那么恢复过程也会很长,所以一些处理器使用了检查点的方式截断日志。
异常一般是在提交的时候才处理,一方面我们需要保证触发异常的指令不是预测执行的,另一方面我们需要在异常发生的时候保证架构状态和异常指令之前所有指令执行完毕一样。