取指单元(Instruction Fetch Unit)负责向处理器输送需要执行的指令, 主要由 Instruction Cache 和 计算下一个指令地址的单元组成。 由于当下的高速处理器每一个周期会至少消耗一条指令,因此对于取值单元来说,下一条指令地址的计算必须和 ICache 的访问并行执行。
对于正常执行的指令来说,只是继续向下取指即可;但对于分支指令来说,由于分支跳转的地址只有在执行完之后才能真正确定,这会非常的耗时。大多数高性能处理器为了应对此问题,都会有分支预测(branch prediction)的功能,来预测分支指令下一条指令的地址,具体需要完成如下两个功能:

上图给出了取指单元的一般结构图。可以看到,下一条指令的地址会有多个来源。取指单元最终选择其中一个结果,从 ICache 中取指:
为了尽可能地减少分支预测错误而造成的重新取指的延迟,ICache 一般用 data & tag parallel 的方式,以达到节省一个 cycle 延迟的目的。
另外,ICache 会倾向于使用 **VIPT(virtual index physical tag) **的方式,让 TLB 访问和 Cache 访问并行,从而减少取指延迟。
对于超标量处理器来说,一个周期会需要多条指令。不过大多数情况下 Cache Line 的长度(典型值为 64Byte) 能够覆盖多条指令,因此单端口的 ICache 就足够了。(不知道最新的架构会不会需要多端口)
(另外在翻看 wikichip 的时候,发现即使是最近的 arm 架构,例如 Neoverse N1,L1 ICache 的端口速率仅有 16 byte/cycle,如果是 4 发射的 EU 的话,连发射所需的指令数都跟不上。但是 fetch 单元却可以输出 4-8 条指令,不知道是不是主要靠 MOPs Cache 来缓存已经解码过的指令来维持发射速率)

和按照程序二进制文件的顺序存储指令的传统 ICache 不同,trace cache 会记录运行时分支 taken or not taken 的情况,将该组合的指令连续存放。由于分支在运行时会有不同的组合,因此同一个指令会在 trace cache 中被存放多份,而不像传统的方式一样只存储一份。这种 cache 组织方式会最大程度的保证指令被连续读取,因此在分支较多的程序中会有比较高的收益。
(但我这里个人理解,只有在分支的 pattern 比较固定时,才能明显避免 PC 来回跳转,不然跟传统的 Cache 没有什么区别。另外配合分支预测的话,传统 Cache 可能也没多少损失?感觉有点奇怪,可能是我对这段的理解不太对劲)
要想实现预测一条指令是否为分支指令,只需要在硬件上添加一个以 PC 为 index 的表即可。该表的表项里面,可以仅使用单个 bit 来标记该指令 是否为分支指令 ,也可以用多个 bit 来更精细的标记该指令具体的分支类型(如条件分支、函数调用、函数返回等等)。
除了指令类型外,表项中还会包括预测的该 分支指令的目标地址 。另外对于条件分支指令来说,还需要一些能够预测该分支是否要跳转的信息。
分支指令的目标地址通常就存放在表项中,如下图所示:

当取指时的 PC 在表中找到匹配的 index 时,说明该指令为分支指令。硬件会接着读取表项,将表项中的目标地址作为预测的跳转地址。表项中存储的目标地址,往往是该分支上一次实际跳转的地址。当发现跳转地址错误时,硬件会更新该表项。
这个既可以在指令解码前就预测指令是否是分支指令,也可以预测分支指令的目标地址。这个表往往被称为 ** branch target buffer (BTB)** 。
在程序中,绝大多数的分支有两个明显的特点:
因此,使用最多的分支指令是 PC relative 类型的分支指令,即目标地址为 PC + offset,这种指令在循环结构中很常见。
对于 offset 固定的 PC relative 指令,虽然目标地址可以直接计算得到,但是需要在分支预测单元加一个加法器,这一行为会导致一个周期的气泡。为了避开这一个周期的气泡_(当然更多的是为了适配更多类型的分支指令)_,目标地址最好还是以预测而非计算的方式得到。
当使用了 BTB 后,对于这类指令,由于目标地址是不变的,目标地址项并不需要更新,每次预测的地址也保证是正确的。而对于目标地址需要在 runtime 才能确定的指令,也能一定程度的预测正确,不必每次都等到目标地址被算出后再跳转。
对于函数来说,由于可能在多个地方被调用,其返回地址会变化很大。用 BTB 虽然可以预测返回地址,但准确率并不高。
不过众所周知,函数的调用是一级一级进行的,返回也是一级一级逆序进行的。因此硬件上用一个 LIFO 的栈结构就能很好的确定函数的返回地址,该栈结构就是 return address stack (RAS)。
当程序调用函数时,硬件会对 RAS 压栈。而当硬件取到的指令被判定为返回指令时(用前面 BTB 的 tag 就能判别),直接将 RAS 的栈顶地址弹出作为预测的返回地址即可。
一般来说,RAS 的项数不会很多(一般为数十项)。当函数调用层级过深,超过 RAS 深度时,旧的项会被清除,保留最新的项。不过对于绝大多数程序而言,这种事情不太会发生。
条件分支是指那些不在 runtime 计算结果,就无法决定是否需要跳转的分支。当然,如果等到硬件执行单元计算出条件结果后再跳转的话,会插入很多气泡,不利于硬件性能。因此需要对这类分支的条件进行预测,决定分支是否要跳转。
分支条件的预测,可以通过静态方法进行,也可以通过动态方法来进行,或者是两者的结合。
一种静态分支预测的方法是:对程序进行 profiling,即真实运行程序并统计每个分支跳转的情况,根据统计结果决定每个分支是否跳转,并通过编译器将结果写到程序中。指令需要有额外的 bit,用来指示该指令被预测为跳转或不跳转_(当然硬件发现预测错误后,还是会 flush 流水线重新取指的)。_
这种方式的好处是硬件设计简单,硬件上几乎不需要什么分支条件预测的逻辑,只需要读编译器写入的 bit(1 bit 就够)即可完成分支条件预测。但缺点也很明显,就是预测的精度可能不是很高。
动态分支条件预测是指硬件单元收集一些过去的程序运行时的信息,通过这些信息来动态的预测分支条件。
一种非常经典的动态分支条件预测器结构如下图所示:

程序过去执行时分支的信息被存放在一个长度为 2^n 的表中,表中的每一项为 2 bit。该表通过指令 PC 的最低 n bits 来索引。每一项的 2 bits 都构成一个有限状态机,用来预测分支跳转条件。
这种预测器被称作 局部分支预测器(local branch predictor )。 这是因为该预测器在进行预测时, 每个分支指令只使用自己过去的跳转状态来进行预测。 对于 highly biased 的分支(不知道该咋翻了,大概意思就是分支在一定时间内要么经常跳转,要么经常不跳转,总之会偏向一边),该分支预测器表现很好。如果分支条件经常是 not taken,那么状态机的状态会是 00;而如果分支条件经常是 taken,那么状态机的状态会是 11;即使分支条件 taken or not taken 发生变化,只要在一定时间内不要频繁波动,状态机也会跟随这分支条件的变化而有效变化。
当然,两个分支指令的最低 n bits 有可能是完全一样的,因此会映射到同一个表项中,这种情况被称为 aliasing。一般来说,aliasing 会降低这类预测器的性能(毕竟会互相干扰嘛)
2-bit 局部分支预测器一般可以达到 >80% 的预测精度,在一些特殊的程序上可以达到 99%。但是对于高性能的处理器来说,由于预测错误带来的性能损失,即使只有 10% 的预测错误也会造成很大的性能影响。预测错误会导致流水线被 flush,取指单元需要重新从正确的分支地址中取出指令,重新填充流水线,这一过程至少需要 10+ 周期。这对于分支较多的程序来说影响会非常大。如果处理器是一个超标量处理器的话,一次分支预测错误带来的损失则会更大。例如处理器重新填充流水线需要 10 个周期,每个周期可以 4 发射的话,一次错误的分支预测相当于损失了 40 条指令的执行。
为了进一步提升分支条件预测器的精度,目前的处理器会引入一种叫 关联预测器(correlating predictor,或被称为 two-level branch predictor) 的硬件单元。不同于局部预测器,关联预取器不仅会根据当前分支指令的历史信息来预测,还会根据它的“邻居”的历史信息来预测。
一种最简单的关联预测器结构如下:

硬件用一个叫 branch global history 的寄存器,来存储最近几次分支的条件结果(每个分支用 1 bit 存储即可)。一般 10~20 个 bit 就足够了。这个寄存器跟指令 PC 做 hash 后,得到的 index 用来从表中选取状态机。更新分支预测结果时,也用同样的方法从表中找到对应的状态机并更新。这种预测器也被称为 gshare。
关联预测器的核心思路是:对于同一个分支指令,根据不同的全局分支条件结果,选用不同的状态机来预测当前分支的条件结果。 当然,关联预测器也无法完全避免 aliasing 的问题。如果每一种分支条件结果、每一个分支都有对应的状态机的话,虽然 aliasing 的问题被避免了,但表项会过大。因此为了保证硬件设计的高效,aliasing 还是不可避免的,但从性能角度上能一定程度上容忍。
branch global history 有很多种跟 PC 做 hash 的方式。例如前面提到的 gshare,两者通过 bitwise OR 操作来作为 index 就能取得不错的效果。当然也可以像下图一样,用多个寄存器来存储全局的历史分支信息。预测时,先通过 PC 选择用哪个寄存器,再通过该寄存器与 PC 的 hash 后得到的 index 从表中选择状态机:

影响关联预测器预测分支条件精度的因素有:
没有适合所有程序的预测器。 有的程序可能更适合使用全局分支信息来进行预测,而有的程序可能恰恰相反。因此,一些处理器会集成多种预测器,并进行混合。
混合分支预测器(hybrid branch predictors) 除了有多个预测器外,还有一个额外的单元用来 选择具体采用哪个预测器的结果(selector) ,其结构如下图所示:

selector 也是根据 PC 和一些全局信息,选定有限状态机来决定使用哪一个预测器的结果。当分支执行的结果出来后,根据多个预测器的表现,来更新选择的策略(当然预测错误的预测器本身也需要更新)。
混合分支预测器不仅仅可以适应不同的程序,它的另一项作用在于: 不同的预测器 warm-up 时间不同 ,因此需要 selector 在程序执行的不同时间点选择合适的预测器。 当程序刚刚启动,或 CPU 刚刚从别的线程切换回来时, 预测器里面存储的信息是与当前程序无关的,无法用来准确预测分支条件, 需要运行一段时间进行 warm-up 来将预测准确率提升到较高水平。 局部预测器因为只使用当前分支的历史跳转情况,因此能够很快的完成 warm-up;而关联预测器虽然最终预测精度较高,但需要更长的时间 warm-up。因此当发生程序刚启动或线程切换的情况时,selector 会先选择 warm-up 较快的局部预测器,一段时间后选择精度更高的关联预测器。
原作者:田子宸
更多回帖