CPU那些事儿 - 前端(上)

421 阅读9分钟

背景

对于大多数做软件的人来说,CPU 的微结构并不是我们每天都需要接触的内容。但是对于做芯片相关的底软工程师而言,我们需要非常了解芯片的很多硬件实现细节,才能对CPU在不同应用场景下进行配置优化以达到最佳的性能;同时,对微架构进行一定程度上的学习了解,也能帮助我们在面对不同硬件特性时,更有效利用其硬件能力进行软件架构设计和编程实现考虑。

1、微架构概述

CPU的微架构一般分为前端和后端两部分,本文先介绍前端相关内容。在我们常用的性能分析 Top-Down 方法论中在顶层将CPU执行时间分成了Frontend Bound、Bad Speculation、Retiring和Backend Bound四大部分,其中Bad Sepculation表示硬件因为错误的投机预测而被浪费的情况,Retiring表示指令正常的完成,而其中的Frontend Bound和Backend Bound则对应了我们下面描述的CPU前后端硬件微架构中的行为(关于Top-Down分析方法的具体讲解后面我们单独写一篇文章介绍)。

top-down.png

  • 前端:负责从内存中取指令,并进行译码和重命名; 影响其性能的关键主要有:指令cache及TLB的大小及带宽、分支预测器的性能、译码Decode和Renaming的宽度等;
  • 后端:负责指令调度、执行、操作结果回写; 影响其性能的原因主要是因为后端没有足够的资源来处理前端发送的指令,这里的资源分成两类,访存资源受限(memory bound)和计算能力资源受限(core bound);

flow-path.png

下面我们就从CPU的前端微架构进行解释说明:

2、取指单元

取指单元 IFU(Instruction Fetch Unit):我们都知道CPU是按照PC寄存器中保存的地址进行取指的,因此取指单元IFU从PC寄存器中拿到指令地址后,从L1 ICache取指令,miss后继续在下一级L2和L3 Cache的查找,直到最终出CORE后经过MMU访问DDR,多级Cache的缓存设计本质上都是为了提升取指的性能,因此,ICache的命中率和吞吐就直接影响了整个CPU流水最前端的性能,而影响命中率的关键因素就是ICache的大小和Cache的替换算法,影响吞吐的关键因素则是Cache的结构(Set-Way、Cacheline大小等);

3、分支预测

BPU(Branch Prediction Unit):对于像ARM64这样的定长指令集来说,很容易就知道下一条取指的指令位于PC+4的地址,但问题是指令在内存中排列的顺序并非程序执行的顺序,例如代码下一句的执行逻辑可能是某些直接跳转(B/BL/CBZ)或者间接跳转(BR/RET)指令,所以如果按照顺序往下取指就会导致取到的指令是无效的错误指令,因此需要BPU来做分支预测,即在还没解析分支条件的情况下,先“猜”跳不跳、跳去哪儿,以便前端继续取指并送入解码器,而由于当前PC指向的指令还未译码,因此只能使用指令的地址信息来进行判断,分支预测器本身的实现是非常复杂的,其中有两个比较通用的做法是采用静态预测+动态预测结合的方式;

  • 静态预测:一般采用硬编码的逻辑实现方式,不需要历史信息,例如如果跳转目标地址小于当前地址,认为是循环,预测为跳转等诸如此类的策略,静态预测的方式逻辑电路实现起来简单但是准确率比较低;
  • 动态预测:则是需要结合历史行为进行学习和预测,典型的实现包含BHT(Branch History Table)、BTB(Branch Target Buffer)、TAGE(Tagged Geometric Predictor)、Loop Predictor等等多种结合方式;

下面我们以BHT(主要用于预测是否跳转)举例进行解释说明,首先我们定义一个2bit的饱和计数状态:
00 -> Strongly Not Taken: 强烈预测不跳转,除非连续两次跳转才会改变方向
01 -> Weakly Not Taken: 预测不跳转,但一次跳转即可改变方向
10 -> Weakly Taken: 预测跳转,但一次不跳转即可改变方向
11 -> Strongly Taken: 强烈预测跳转,除非连续两次不跳转才会改变方向

这个计数器会根据实际执行的结果逐渐递增或逐渐递减的更新状态值,假设我们有1024个BHT表项,由于ARM64是4 byte定长指令,因此我们就可以取指令地址的[11:2]位作为索引查表,假设初始表项的结果是 00 表示不跳转,这个结果可能不正确,但是在后续流水译码执行时此处是一个跳转逻辑,就可以通过计数加1的方式更新该表项结果,以此来动态的更新该地址的跳转预测结果,当然还可以加上一个GHR的全局历史信息(记录该地址最近多次的跳转结果)来提升预测跳转的准确性,这也给我们软件工程师带来一个启发,我们经常避免不了会写一些复杂的循环嵌套代码,当我们在使用TOP-DOWN进行性能定位的时候发现某处代码有比较大branch-miss时,可以考虑拆分循环嵌套,改用最简单直接的顺序逻辑进行替换(后续Top-Down的讲解专题会详细阐述);

BHT.png

BHT给出了是否跳转的预测结果,具体的跳转地址则需要BTB给出,BTB简单理解就是一个大的多级缓存表,用于存储记录已知分支指令的目标地址,因此BHT和BTB结合就能基本给出一个分支指令的下一条取指地址;除了上面这些部件,最先进的CPU还在在前端增加例如TAGE这种长历史信息表项等等一些新的设计来提升分支预测的准确性,核心就是因为前端供给的指令如果不正确,那在后级执行过程中需要Flush流水,会带来非常大的性能损失;同时分支预测器还有一个RAS(Return Address Stack)配合,本质上就是用一个栈队列存放上一个跳转返回地址,深度越大,硬件相应支持函数嵌套能力越强;最终这几个部件协同配合完成前端分支预测的工作:

image.png

4、前端结构

基本上前端最重要的就是上面这两部分,分支预测器负责提供取指地址,IFU负责从指令Cache中获取指令,两者相互配合为后级流水源源不断提供指令,根据他们之间的关系前端又分为耦合式前端(Coupled Frontend)和分离式前端(Decoupled Frontend)两种设计;

耦合式前端的设计是BPU预测出下一次的地址后立即发送给IFU进行取指操作,而分离式前端的设计是BPU作为生产者不停产生取指地址缓存到内部,取指单元从缓存中读取地址,两者之间通过FTQ(Fetch Target Queue)进行交互,这样分支预测器独立运行,可以投机抢跑,并且抢跑的分支信息还可以提前预取到L1,这种分离式的设计性能上有很大的提升,但是有可能会出现比较大的分支信息记录,因此分离式前端的一个显著特点就是需要比较大容量的BTB来进行缓存分支信息,Apple M1就是分离式的前端设计,为了解决大容量的BTB需求,设计上将L1 ICache可作为最后一级BTB进行查询,Android阵营的Arm Cortex-X系列最新几代的架构也是分离式前端的设计;当前比较先进的CPU支持2 taken/cycle的设计就是每次预测两个分支,一般对应后端的BRU也至少有两个,因为当前地址的预测信息存放在BTB里,因此支持2 taken/cycle的关键就是BTB要支持双端口的设计;

FE.png

5、前端性能指标

此外,我们再介绍几个前端比较重要的性能指标:

  • Fetch Width:表示Cache取指带宽,指IFU每个cycle能从ICache中能取多少条指令,某些CPU支持Micro Cache微指令缓存,因此还会有Micro取指宽度来衡量微指令取指性能,Fetch Width主要受硬件实现时序影响,同时还跟指令缓存的Cacheline有关,例如Apple的M系列能在一个周期内完成一条Cacheline的取指操作,即支持16 Inst./Cycle的前端取指能力;
  • Decode Width:表示每个Cycle能译码多少条指令,Decode宽度决定了同时能有多少条指令发送给后级流水线进行操作,前端取指的指令是机器码,需要通过译码器进行译码成Micro Ops微操作指令发送后级流水部件,例如一条加法指令:
    ADD X3, X1, X2
    可能对应了4条Micro Ops微指令
    |ReadReg X1 → Temp1 | 从寄存器X1读入数据 |
    |ReadReg X2 → Temp2 | 从寄存器X2读入数据 |
    |Add Temp1, Temp2 → Temp3 | 加法操作 |
    |WriteReg Temp3 → X3 | 写入结果 |
    4 条Micro Ops,我们前面讲到的某些CPU为了避免每次都译码耗时耗电,专门设计了Micro Cache缓存之前已译码指令的 Micro Ops,即以PC地址为Key进行Micro Cache查找,来跳过 Decoder 阶段,从而加速前端;而对于ARM系的定长指令集来说,由于Decode本身的译码实现复杂度比较低,单独加一个Micro Cache 的Area/Power是不划算的,因此最新的ARM架构CPU很多都已经取消了这一特性,而x86架构的很多CPU还保留了这一设计特性。

6、小结

以上基本上就涵盖了CPU前端的关键部分,主要以基本的硬件原理为重点进行介绍,下一篇我们将重新站回软件视角,介绍如何通过软件去探查这些硬件的设计细节。

PS:本文同步在微信公众号和知乎同步发布

公众号二维码.jpg