概览
为了使处理器里的流水线全速工作,我们要尽量去掉指令和指令之间的依赖。指令之间的
依赖有WAW,WAR,RAW以及控制依赖,只有RAW和控制依赖是真依赖,其他两种依赖都可以
靠寄存器重命名来缓解。load/store地址之间也存在着依赖。
一个周期可以去取多条指令送到流水线里执行,这个处理器就是超标量处理器了。超标量
处理器又分顺序执行和乱序执行。所谓乱序执行,是指处理器内部执行指令的时候是乱序
执行的,从程序员的视角看,程序是按顺序执行的,不过,对于不同处理器会有不同程度
的放松,比如在没有数据依赖的时候,对不同地址的load/store可能是乱序完成的。
一个周期执行多条指令并不是说一个处理器里有多条一样的流水线,一个处理器里有多个
不同的执行单元就可以把不同的指令发射到不同的执行单元里执行。
一般我们可以把执行单元分为:ALU(ADD/LOGIC),ALU(MUL/DIV), LOAD/STORE, FP/SIMD等。
一个超标量处理器的大概模型如下:
1 | +-------+ +----+ |
一般,我们把取指(IF)、译码(ID)看做前端(front-end),发射(issue)、执行(EX)、内存操作(MEM)
写回(WB)以及提交叫做后端(back-end)。
cache
todo
虚拟存储器
todo
分支预测
想要充分使用流水线就要预取指令执行,顺序执行的指令直接预取就可以,但是到了分支
的时候,就要选择一条分支来预取,这时就存在各种分支预测的实现方式。分支预测的各种
实现可以暂时当做黑盒来对待。预取的指令可以提前在流水线里执行,但是在分支结果出来
之前不能提交。分支结果出来后,如果预取分支是对的,那么直接提交执行的结果就可以,
但是,如果分支取错了就要把之前计算出的结果和正在流水线里跑的错误预取的指令和状态冲刷
掉,而且还要恢复到指令分支时的状态,继续取正取的分支放到流水线里运行。
可见,如果分支预测的不准就会老是冲刷流水线,后端的执行单元得不到充分利用,因此
而导致的性能问题叫做bad speculation bound。
分支预测失败的时候要尽快回退到分支点的状态,硬件可以使用checkpoint的方式恢复,
大概是分支预取的时候把整个状态统一保存,分支预测失败的时候统一一把恢复回来。
指令解码
解码阶段需要做寄存器的重命名,有时还有把一条指令内部拆分成几条指令。对于超标量
处理器,可以同时解码多条指令。这个阶段还是顺序进行的,比如4发射的处理器,就同时
有四条指令送去解码。
为了使的程序执行对外保持逻辑顺序,处理器实现里一般使用重排序缓冲(ROB reorder buffer)
在提交之前重排序。ROB的设计逻辑很简单,就是以指令取入的顺序在取入的时候就按顺序
存在ROB这个队列里,ROB为每个指令都维护了相关的状态,ROB里的指令提交的时候是顺序
进行的,这样就可以保证先进入的指令先离开处理器。这会导致ROB会被执行慢的指令堵住,
不过这也没有关系,虽然当前的执行堵住了,但是后面的指令已经在ROB里就绪,一旦这个
堵住ROB的指令提交,后面的完成的指令可以一次都提交了。当然受ROB深度的限制,一旦
ROB慢了,就会反压前端的取指单元,这样的因为后端没有及时执行导致的性能问题就叫做
back-end bound。
我们可以先跑一个慢的指令比如浮点除法,后面不断的跑NOP指令,这样ROB会被填满,导致
back-end bound。执行单元以及执行的issue queue满了,都会导致前端反压,构造一个
这样的场景看看?
分支预测失败进行回退的时候也要清ROB里的内容。
寄存器重命名
使用寄存器重命名消除WAW和WAR依赖。具体的实现方式有很多,基本思路就是要识别出代码
中的依赖,然后重命名消除依赖,指令执行完毕后要把重命名的寄存器重新返回到软件感知
的构架寄存器上。硬件为了重命名要维护重命名相关的表格。当分支预测失败的时候,也要
回退寄存器重命名占用的相关资源。
发射
进入发射阶段之前已经完成寄存器重命名,发射把指令分发到各个执行单元之前的issue queue
里,issue queue里的各个指令在资源OK时就被发送到对应的执行单元里执行。这里的资源
OK是指指令依赖的操作数的值都计算出来了、相关的计算单元空闲。可见,发射阶段要处理
的逻辑是:1. 明确相关的依赖,依次决定何时执行指令;2. 需要有逻辑检测指令是否满足
执行条件。
详细逻辑后续看需要再补齐。
执行
执行阶段除了要执行运算任务,还有就是要考虑旁路网络的逻辑。当指令有前后之间有依赖
的时候,后一条指令需要等到前一条指令提交后再执行,这样会使流水线闲置,处理器里
可以加入一定的前递逻辑,就是这里的旁路网络,这个逻辑在前一条指令计算结果得出后
就可以把结果直接前递给后一条指令执行。
对于load/store这种存储指令,因为他们执行的时候可能有比较大的时延,需要考虑相应
的优化办法。一般的优化办法是,对于没有数据依赖的load/store指令,放松条件允许他们
乱序执行,这本书里介绍的是,一般多个store指令是按循序执行的,没有数据依赖的时候
load指令可以提前到store指令前执行,因为load指令常常作为被依赖的指令。
提交
提交阶段围绕ROB展开,ROB里的指令在完成时就可以提交,提交后软件就可以感知,可能
是架构寄存器的值被更新,也可能是cache内容被更新。分支预取的指令也会排到ROB里,
所以,当分支预测失败的时候,ROB还要有相关逻辑处理资源回退。处理器在执行指令的
时候可能会出现异常和外部中断,比如存储器访问指令访问了错误地址、访问的物理页面
不存在、外部设备给处理器发了一个中断,处理器要做到所谓精确异常,就是引发异常指令
之前的指令都可以正确执行,引发异常的指令上报异常,处理器的PC需要跳到异常向量处
执行代码。处理器很难在指令执行的时候就同步处理如上的逻辑,当执行指令异常发生的
时候,处理器把指令异常的信息记录到ROB里,当指令提交的时候异常指令就可以得到处理,
可以看到流水线里还存在异常指令之后的指令,异常处理逻辑需要把流水线里的这些残留指令
排空再跳到异常向量处执行代码。可以看到异常很多的时候,不断排空流水线也会导致性能
损失。外部中断的处理逻辑和异常的基本类似。
注意,上面提到的store buffer存在于处理器内部,在store指令提交的时候把store buffer
里的内容写到存储器上。如果写存储器时延太长,store buffer就有可能满,从而反压之前
load/store执行单元的issue queue,从而又有可能反压前端。
在多核的情况下,因为有store buffer和invalid queue会带来乱序完成,这个逻辑也需要
整合进来。初步看,这个逻辑和上面的逻辑似乎是两个正交的逻辑。
还有一个问题是,处理器怎么划定依赖的范围,比如,如果后续一个指令依赖之前的一个
指令,之间间隔很大,处理器会不会看不到这样的依赖,先执行完成了后面的指令?仔细想下
是不会出现这样的问题的,处理器在当前整个处理状态下,一定可以检测出所有的依赖,所以
如果相互依赖而且跨度较大的两条指令,在处理器的当前状态内(包含整个流水线的范围内)
是不会出问题的,如果第一条在流水线里,后面的一条在流水线外,因为后一条还没有执行,
所以也不会出问题。