编程基础(三)——体系结构

目录

一、概述

二、流水线

2.1 基础知识

2.2 顺序实现

2.3 经典的五级流水线

2.4 流水线冒险

2.4.1 结构冒险

2.4.2 数据冒险

2.4.3 控制冒险

2.5 动态调度

2.6 超标量 & 超线程

三、参考


一、概述

主要是按照自己的思路对体系结构书籍和文章的一些罗列和总结,主要是作为性能优化的一个基础,梳理自身的知识,备忘,可能其中有理解上的错误,会不断修正。

二、流水线

2.1 基础知识

ISA,一个处理器支持的指令和指令字节级编码称为它的指令集体系结构。ISA实际上在编译器编写者和处理器设计人员之间提供了一个概念抽象层【2】,在阅读一些体系结构相关的书籍或文章时,还是有一些术语是和从程序员角度理解的有些不同,需要进行说明,以免引起混淆。

在进行汇编语言的编程时,我们一般都和寄存器打交道,以x86_64为例,通常我们关注%rax,%rbx等16个通用目的寄存器,当然还有%rip(PC),状态寄存器,状态码。在cpu设计的硬件电路中也存在很多寄存器,为了避免寄存器这个笼统的称呼引起理解上的误差,我们称通用目的寄存器为程序寄存器,其他称为硬件寄存器,如后面将看到的流水线寄存器。在实现上,程序寄存器存在于CPU中的一个寄存器文件(register file)中,有时也会称呼寄存器堆。寄存器文件就是一个小的,以寄存器ID作为地址的随机访问存储器。

一个典型的寄存器文件向下图这样:

  • 寄存器文件有两个读端口,一个写端口,这样一个多端口随机访问存储器允许多个读操作或者写操作

在引入流水线的概念之前,我还是按照CSAPP中,从演进的角度,把过程中一些概念说明白,前面说ISA就是指令和指令编码,一个简化的指令编码如下:

  • 第一个字节编码指令类型,以code:function,code代表不同指令类型,function代表功能
  • rA,rB在指令中是以寄存器ID存在,其和程序寄存器的对应关系如下表,我们就是通过这个ID去寄存器文件中去取对应程序寄存器的值的

  • 注意编码为数字F表明没有寄存器其对应,这不是什么大问题,也不会增加开销

2.2 顺序实现

1. OPq rA, rB                                   //两个操作数是寄存器rA, rB
2. rmmovq rA, D(rB)                             //store,  register to memory 
3. mrmovq rA, D(rB)                             //load ,  memory to register

处理一条指令需要很多操作,主要包括如下阶段:

  • 取指 (instruction fetch): 从内存读取指令,通过code:function确定指令类型和指令功能,同时取得一个或两个寄存器ID(rA或rB),也可能是立即数,通过当前PC和指令长度计算下一条地址,保存在valP中
  • 译码 (instruction decode):通过寄存器ID,从前面寄存器文件中读取程序寄存器rA,rB的值,pushq指令会读取%rsp
  • 执行(execution),如果是(1),使用ALU进行逻辑/算数运算,如果是(2)(3) 计算内存引用有效地址,也可能增加/减少栈指针,如果是条件传送,验证条件码和传送条件,如果是跳转指令,决定是不是选择分支
  • 访存(memory) , 可以将数据写入内存,或从内存中读出
  • 写回 (write back),将结果写回寄存器,(1)(3)会执行这一步
  • 更新PC (update PC) 将PC更新为下一条指令的地址 

下面的图片说明了典型指令每个阶段的操作,直接贴图:

将上面描述的部件组合起来就是一个指令的顺序实现,这种设计一个周期执行一条指令,这样时钟必须非常慢,才能使信号能在一个周期内传播所有阶段,所以执行速度很慢。

2.3 经典的五级流水线

2.2中的顺序实现不能充分利用硬件单元,因为每个单元只在整个时钟周期的一部分内才被使用,人们引入了流水线来解决这个问题。流水线是一种将多条指令重叠执行的实现技术,不同步骤并行完成不同指令的不同部分,这些步骤每一步都称为流水级,虽然流水线没有提高单条指令的执行时间,但是它提高了整个系统的指令吞吐,不过它也会增加延迟。一个经典的五级流水线可以由下图抽象的描述:

简单来说,流水线就是将顺序执行的各个执行部件之间加上流水线寄存器存储每个流水线阶段的结果,一个典型的流水线设计如下:

  • 可以看到流水线寄存器(蓝色)和每个阶段的关键部件:指令内存,寄存器文件,ALU,数据存储器
  • 不同的是PC指针的更新要放在ID,因为要在下一条取值前完成更新。

2.4 流水线冒险

流水线要达到的目标是每周期执行一条指令,当在实际中是往往难以达到的,因此有很多因素会导致流水线无法按照理想状态执行,称为流水线冒险,常见的流水线的冒险有以下三种:

  • 结构冒险 (structural hazards) :在重叠执行模式下,如果硬件无法同时支持指令的所有可能组合方式,就会出现资源冲突,从而导致结构冒险
  • 数据冒险(data hazards): 根据流水线中的指令重叠,指令之间 存在先后顺序,如果一条指令取决于先前指令的结果,就可能导致数据冒险
  • 控制冒险(control hazards):分支指令及其他改变程序计数器的指令实现流水化时可能导致控制冒险

2.4.1 结构冒险

  1. IF和MEM同时访问内存会造成冲突,引入icache和dcache避免资源冲突
  2. ID和WB阶段读取和写入寄存器文件产生冲突,解决方法是上文中的多个读端口和写端口的寄存器文件

2.4.2 数据冒险

这里先说明指令相关的概念,为了开发指令级并行,需要判断指令之间的依赖性,如果指令是并行的,只要硬件资源足够,就可以同时执行,如果指令是相关的,尽管可以部分重叠,但必须按序执行。

指令相关包括三种:

  • 数据相关(data dependent,也称真数据相关true dependence)
  • 名称相关 (name dependence)
  • 控制相关()

如果以下任一条件成立,则说指令j数据相关于指令i:

  1. 指令i生成的结果可能会被指令j用到
  2. 指令j数据相关于指令k,指令k数据相关于指令i,相关链可以很长

以上两点说明两条指令间如果存在数据流动就是数据相关的,如果两条指令是数据相关的,那它们必须顺序指令,不能同时执行或不能完全重叠执行。这种相关意味着两条指令之间可能存在由一个或者多个数据冒险构成的链。

注意相关是程序的一种属性某种给定相关是否会导致检测到实际冒险,这一冒险又是否会实际导致停顿,这都属于流水线的性质。

名称相关,当两条指令使用相同的寄存器或者存储位置,但与该名称相关的指令之间并没有数据流动时,就会发生名称相关

  1. 当指令j对指令i读取的寄存器或存储器位置执行的写操作时就会在指令i和指令j之间发生反相关(anti-dependence)
  2. 当指令i和指令j对同一个寄存器或存储器位置执行写操作时,发生输出相关(output dependence)

上述两种名称相关都可以通过寄存器重命名解决,可以是编译器静态完成的也可以是硬件动态完成的

控制相关放到后面说,接下来看一下上述两个指令相关和流水线数据冒险的关系,按照上面描述的指令相关可以将数据冒险分为三类(考虑两条指令i和j,i根据程序顺序放在j前):

  1. RAW(写后读),j试图在i写入一个源位置前读取它,所以j会错误的获取旧值,这一冒险是最常见的类型,与真数据相关对应
  2. WAW(写后写),j试图在i写一个操作数之间写该操作数。最终会导致错误的顺序执行,这种冒险和输出相关对应
  3. WAR(读后写),j尝试在i读取一个目标位置之前写入该位置,i会错误的获取新值,这一冒险源于反相关。

上述三类冒险理论上是指令相关的,因此必须按顺序指令,这样就是导致流水线停顿,下面看一下解决这些冒险的是如何促使流水线的演进的。

再强调一遍,我们的目的是指令并行,尽可能利用流水线,因为按照程序顺序执行相关指令会有流水线停顿(stall),因此我们考虑将不相关的指令提前,来掩盖流水线停顿。另一方面,我们仔细分析指令相关造成的三类数据冒险,其中有的也不是真相关,也可以通过某些方式解除指令相关限制。

首先,和WAW对应的输出相关、和WAR对应的反相关,由于他们与名称相关的指令没有数据流动关系,因此通过一种称为寄存器重命名的方法可以解除这种名称相关,意味着这两类情况可以自由的乱序。接下来,RAW, 之所以被称为真数据相关,这种操作之间存在数据流动,是真正无法乱序的,即便如此硬件通过一种称为转发(forwarding,也有称旁路,短路)的方法来解决RAW中的一部分冒险,解释如下:

1. DADD R1, R2, R3
2. DSUB R4, R1, R5
3. AND  R6, R1, R7

以简单五级流水线来分析,后面两条指令和第一条是真数据相关(R1), 首先R1在WB阶段更新,2中在ID阶段用到R1值,需要等待两个周期,另外3在ID阶段也要用到R1,需要等待一个周期。转发解决该问题的工作方式如下:

  1. 来自EX/MEM和MEM/WB流水线寄存器的ALU结果总是被反馈到ALU输入端
  2. 如果转发硬件检测到前一个ALU操作已经对当前的ALU操作的源寄存器进行了写入操作,则控制逻辑选择转发结果作为ALU输入,而不是选择从寄存器堆中读取的值。

示意如下:

 

遗憾的是,并不是所有真数据相关都可以通过转发技术解决

1. LD R1,0(R2)
2. DSUB R4, R1, R5
3. AND R6, R1, R7

上述这种情况不同于背靠背ALU操作的情景,1在MEM阶段才能确定R1中读入的值,而2指令需要在ID阶段就得到该值,这显然太晚了,由于这种转发路径必须进行时间上的回退操作——计算机设计师还不具备这个能力!载入指令有一种不能单由转发来消除的延迟,需要增加一种称为流水线互锁,以保持正确执行的模式。TO DO

总结一下到目前为止, 我们的目的是通过在流水线停顿的时候插入指令避免流水线气泡,而我们也分析了那些:

  • 消除指令相关的WAR、WAW指令的方法

  • 可以用转发技术解决的一部分RAW的相关

  • 不相关的指令

上述指令都可以通过调度来改变执行顺序,使用乱序执行来达到充分利用流水线的效果。可以使用编译器对代码进行调度,称之为静态发射或静态调度,由硬件对代码进行调度称为动态调度,动态调度的优势在于:

  1. 允许针对一种流水线编译的代码在不同流水线上高效执行
  2. 编译代码时可能不知道相关性,利用动态调度可以处理
  3. 允许处理器处理预料之外的延迟,如缓存缺失,可以在等待时执行其他代码

控制相关决定了指令i相对于指令分支的顺序,是指令i按照正确的程序顺序指令。

if (p1) {
    s1;
}

if (p2) {
    s2;
}

s1与p1相关,s2与p1相关,但与p1不相关。

控制相关会施加两条约束环境:

  1. 如果一条指令与一个控制相关,那么就不能把这个指令移到这个分支之前,使它的行为不再受控于这个分支
  2. 如果一条指令与一个分支没有控制相关,那就不能把这个指令移到这个分支之后

上述说明了在乱序执行过程中一种不能乱序的情况。

2.4.3 控制冒险

以经典流水线为例,我们必须在IF阶段确定下一个PC的值,这样才能让流水线没有停顿,但是如果取出的是分支指令,只有几个周期后的EXE阶段才能知道是否要选择分支【2】。分支处理的一个简单方法是,一旦在EXE期间检测到分支,就对该分支之后的指令进行重新取值,这样无论分支取的地址是正确还是错误,都会废弃(冻结或者冲刷流水线),这里既造成停顿(控制冒险),也有效率上的浪费(分支未被选中时尽管正确的提取了指令仍然要丢弃)。

延迟分支 TODO

静态分支预测动态分支预测

2.5 动态调度

上面说明了动态调度的概念,没有展开,本节来看一下动态调度是如何克服数据冒险的,在经典的五级流水线中,会在ID阶段检查结构冒险和数据冒险,当一个指令可以无冒险执行时,从ID阶段发射出去。

将一条指令从指令译码(ID)移入此流水线的EX的过程通常称为指令发射(issue),已执行这一步骤的指令称为已发射

看下面的指令:

1. LD  R0,  (R2)
2. ADD R10, R0, R8
3. SUB R12, R8, R14

其中1,2有指令依赖关系,但是3和1,2是不相关的,在有程序顺序限制的条件下,3不能执行,如果放开这一条件,就可以消除这一限制,即可以乱序执行,为了能进行乱序执行,我们将ID拆分成以下两个阶段:

  1. 发射:译码指令,检查结构性冒险
  2. 读操作数:等到没有数据冒险,读取操作数

IF仍位于发射阶段之前,可以把指令放到指令寄存器中也可以放到指令队列中。

Tomasulo算法进行动态调度,TO DO, 这里着重说明该算法引入的在当今流行cpu设计中仍然广泛存在的部件及其功能,便于后续了解不同的微架构。我们前面说到,使用寄存器重命名的方法消除WAW, WAR,Tomasulo方案中寄存器重命名由保留站提供,保留站是指令的功能部件的缓冲区,我们重点关注一条指令在乱序执行下执行的步骤:

  1. 发射:从指令队列获取下一条指令,指令队列FIFO确保发射是保序的。如果有一个匹配的保留站为空,则将这条指令发送到这个站中,如果操作数当前已经在寄存器,也一并发到站中。如果没有空保留站,则存在结构性冒险,该指令会停顿,直到有保留站或者缓冲区被释放位置,如果操作数不在寄存器中,则一直跟踪将生成这些操作数的功能单元,这一步对寄存器进行重命名,消除WAR,WAW冒险。
  2. 执行:如果还有一个或者多个操作数不可用,则在等待计算的同时监视公共数据总线。当一个操作数变为可用时,就将它放到任何一个正在等待它的保留站中。当所有的操作数都可用,则可以在相应的功能单元中执行运算。通过延迟指令执行,直到操作数可用为止,可以避免RAW冒险。
  3. 写结果:在计算出结果之后,将其写到CBD上,再从CBD传送给寄存器和任意等待这一结果的保留站

图 TODO 

  • 在同一个时钟周期,同一功能单元可能会有几条指令变为就绪状态
  • 载入缓冲区(load buffer)和存储缓冲区(store buffer)保存来自和进入存储器的数据或地址,其行为方式和保留站相同,他们的执行过程需要两个步骤,第一步是在基址寄存器可用时计算有效地址,然后将有效地之放在载入缓冲区或者存储缓冲区中,载入缓冲区的载入指令在存储器单元可用时立即执行,存储缓冲区的存储指令要等待存储的值,然后将其发送给存储单元

个人理解,通过将ID拆分成两个阶段(发射,缓冲区,),在功能单元可用时——功能单元对应的缓冲区(RS, 存储缓冲区,加载缓冲区)空闲,应该时缓冲中仍有条目(entry)可用,即此时没有结构冒险,将指令发射到缓冲区中,这些缓冲区通过某种总线(CBD)连接寄存器文件,存储器,当数据可用的时候通过总线将操作数取到缓冲区中,此时指令就绪,如果多条指令就绪还要硬件进行策略分发(dispatch),指令此时是乱序执行的。

为了实现基于硬件的推测,即猜测执行,其关键思想在于运行指令乱序,但强制按序提交(TO DO)。在上述算法附加步骤指令提交。实现指令提交需要增加一种缓冲区,称为重排缓冲区(ROB)在采用推测时,寄存器堆要等到指令提交之后才会更新。这种支持硬件推测的实现在上述三步之后增加了一步:

  • 提交,这是完成指令的最后一个阶段,在此之后仅留下结果,这一提交阶段也称为retirement, 提交时有三种不同的操作序列,当一个指令到达ROB头部(准备出队)而且其结果出现在缓冲区中,进行正常提交——此时处理器用结果更新其寄存器,并从ROB清除该指令。提交存储与寄存器类似,不过更新的是存储器。当预测错误的分支到达ROB头部,它指出ROB是错误的,那么ROB被刷新,如果对该分支预测正确,则分支完成提交。

不同微架构上述部件拆分可能更细,对各部件的称呼也略有不同,其实现也可能大不相同,需要专门研究,但基本的原理,应该是相似的。

2.6 超标量 & 超线程

单发射与多发射

简单的流水线每个cycle发射一条指令,称为单发射。由于存在流水线冒险,实际上流水线CPI (cycle per instruction)始终大于1。可以通过两种方式减小CPI,一种方式是增加流水线的深度,即所谓超流水线,这种方式的确定在于不能无限增加流水线的级数,由于有流水线寄存器造成的延迟,不可能无限提高流水线的吞吐。另一种是增加流水线部件,形成多条流水线,这样每个cycle可以发射多条指令,称为多发射。下图展示了这种变化:

右图展示一个并行流水线,回忆一下上文,如果我们采用动态调度和硬件推测的执行,需要对基本的流水线做一下改动,这里引出一个超标量的概念:基于动态调度的多发射称为超标量(superscalar)【3】,那么我们的流水线结构进化成:

可以看到,它与线性的流水线有很大不同,增加了非线性通路。

以上就是CPU在指令级并行(ILP)做的优化,有了现代处理器的雏形。拥有超线程(Hyper-Threading)的处理器将两个虚拟的处理器暴露给共享的乱序执行部件。它们共享一个重排序缓存和乱序执行部件,让操作系统认为它们是两个独立的处理器,看上去就像这样:

超线程的处理器拥有两个虚拟的处理器,从而可以给乱序执行部件提供更多的数据。超线程对一般的应用程序都有性能提升,但是对一些计算密集型的应用,则会迅速使得乱序执行部件饱和。在这种情况下,超线程反而会略微降低性能。但这种情况毕竟是少数,超线程对于日常应用来讲通常都能够提供大约一倍的性能。

三、参考

【1】支撑处理器的技术           Hisa Ando 著

【2】深入理解计算机系统        Randal E. Bryant & David R. O'Hallaron 著

【3】计算机组成与设计 硬件/软件接口    David A. Patterson & John L. Hennessy 著

【4】计算机体系结构量化研究方法           John L. Hennessy & David A. Patterson 著

【5】CPU流水线的探秘之旅

猜你喜欢

转载自blog.csdn.net/whenloce/article/details/85833533
今日推荐