<>1.写在前面

前面我介绍了如何简单的构建一个CPU的,但是我们似乎没有知道指令在CPU中是如何流转的,是单个时间周期中就执行一个指令吗?还是什么方式呢?

<>2.流水线概述

流水线是一种能使用多条指令重叠执行的实现技术。目前,流水线技术广泛应用。我们先来看一个非流水线的例子。

* 将一批脏衣服放入洗衣机。
* 洗衣机洗完后,将湿衣服取出并放入烘干机。
* 烘干机完成后,将干衣取出,放在桌子上并叠起来。
* 叠好后,请你的舍友帮忙把衣服收好。
但是如果换一种思路,当第一批衣服洗完过后,这个时候洗衣机就是空着的了,为什么我们不能去洗第二批的衣服呢。于是有了下面的方式。

当第一批衣服从洗衣机中取出并放入烘干机后,就可以把第二批衣服放入洗衣机。当第一批衣服烘干完成后,就可以把它们放在桌上叠起来,同时把洗衣机中喜好的衣服放入烘干机,再将下一批脏衣服放入洗衣机。接着让你的舍友把第一批衣服从桌上收好,你开始叠第二批衣服,烘干机开始烘干低三批衣服,同时可以把第四批衣服放入洗衣机。

流水线的矛盾在于,对于一双脏袜子,从把它放入洗衣机到被烘干、叠好和收起的时间在流水线中并没有缩短;然而对于喜多负载来说,流水线更快的原因是所有工作都在并行的执行。所有单位时间能够完成更多工作,流水线提高了洗衣系统的吞吐率。因此,流水线不会缩短一次洗衣的时间,但是当有很多衣物需要洗时,吞吐率的提高减少了完成整个任务的时间。

好的回归我们问题的本质,同样的道理我们也可以试用计算机的CPU,具体的如下五个步骤:

* 从存储器中取出指令。
* 读寄存器并译码指令。
* 执行操作或计算地址。
* 访问数据存储器中的操作数(如有必要)
* 将结果写入寄存器(如有必要)
我们可以看下如下的例子:

假设指令或数据存储器访问为200ps,ALU操作为200ps,寄存器堆的读或写为100ps,在单周期模型中,每条指令的执行需要一个时钟周期,所以时钟周期必须满足最慢的指令。

非流水和流水的执行的效率如下:

所有的流水线阶段都需要一个市州周期,所以流水线的时钟周期必须足够长以满足最慢的操作。就像单周期设计中,即使某些指令的执行可能只需要500ps,但时钟周期要满足最坏的情况800ps。流水线的市州周期也必须满足最坏情况200ps,尽管有些阶段只需要100ps。流水线仍然提高了4倍的性能改进:第一条和第四条指令之间的事件是3*200ps=600ps。

总结:流水线技术通过提高指令的吞吐率来提高性能,而不是减少单个指令的执行时间。由于真实程序会指令数十亿指令,所以指令吞吐率是一个重要指标。

<>2.1面向流水线的指令系统设计

第一,所有RISC-V指令长度相同。这个限制简化了流水线第一阶段取指令和第二阶段指令译码。

第二,RISC-V只有几种指令格式,源寄存器和目标寄存器字段的位置相同。

第三,存储器操作数只出现在RISC-V的load或store指令中。这个限制意味着可以利用执行阶段来计算存储器地址,然后在下一阶段访问存储器。

<>2.2流水线冒险

流水线中有一种情况,在下一个时钟周期中下一条指令无法执行。这种情况被称为冒险,主要有如下的三种冒险。

*
结构冒险

第一种冒险叫做结构冒险。即硬件不支持多条指令在同一时钟周期执行。在洗衣例子中,如果用洗衣烘干一体而不是分开的洗衣机和烘干机,或者如果你的舍友正在做其他事情而不能收好衣服,都会发生结构冒险。

*
数据冒险

由于一个步骤必须等待另外一个步骤完成而导致的流水线停顿叫做数据冒险。在计算机流水线中,数据冒险源于一条指令依赖于前面一条尚在流水线中的指令。

那么如何解决呢?

不需要等待指令完成就可以尝试解决数据冒险。对于上面的代码序列,一旦ALU计算出加法的和,就可将其作为减法的输入。向内部资源添加额外的硬件以尽快找到减少运算项的方法,称为前递或旁路。
我们可以看下如下的例子:
add x19,x0,x1 sub x2,x19,x3

仅当目标阶段在时间上晚于源阶段时,前递路径才有效。例如,从第一条指令存储器访问阶段的输出到下一条指令执行阶段的输入不能存在有效前递路径,否则意味着时间倒流。

前递的效果很好,但不能避免所有的流水线停顿。假设第一条指令是load
x1而不是加法指令,这个时候就会在第一条指令的第四个阶段之后,sub指令所需的数据才可用,这对于sub指令第三个阶段的输入来说太迟了。因此,即使使用前递,流水线也不得不停顿一个阶段来处理载入-使用型的数据冒险。我们可以看下如下的图:

该图包含流水线的一个重要概念,正式叫法是流水线停顿,但通常俗称为气泡。那么如何处理这种复杂的情况,我们应该由软件对代码进行重新排序以尽量避免载入-使用型流水线停顿。

可以看下如下的例子:

重排代码以避免流水线停顿

考虑以下C语言代码段:
a = b + e; c = b + f;
最后生成的汇编的代码如下:
ld x1,0(x31) // Load b ld x2,8(x31) // Load e add x3,x1,x2 // b + e sd
x3,24(x31) // store a ld x4,16(x31) // Load f add x5,x1,x4 // b + f sd
x5,32(x31) // store c

两条add指令都有冒险,因为它们分别依赖于上一条ld指令。请注意,前递消除了其他几种潜在冒险,包括第一条add指令对第一条ld指令的依赖,以及sd指令带来的冒险。把第三条ld指令提前为第三条指令可以消除这两个冒险:
ld x1,0(x31) // Load b ld x2,8(x31) // Load e ld x4,16(x31) // Load f add
x3,x1,x2 // b + e sd x3,24(x31) // store a add x5,x1,x4 // b + f sd x5,32(x31)
// store c
在具有前递的流水线处理器上,执行重新排序的指令序列将比原始版本快两个时钟周期。

最后引出一个特点,
即每条RISC-V指令最多写一个结果,并在流水线的最后一个阶段执行写操作。如果每条指令有多个结果要前递,或者需要在指令执行的更早阶段写入结果,前递设计会复杂得多。

控制冒险

需要根据一条指令的结果做出决定,而其他的指令正在执行。

现在有如下的情况,假设洗衣店的工作人员接到一个令人高兴的任务:清洁足球队队服。根据衣服的污浊程度,需要确定清洗剂的用量和水温设置是否合适,以致能洗净衣服又不会由于清洗剂过量而磨损衣物。在洗衣流水线中,必须等到第二步结束,检查已经烘干的衣服,才知道是否需要改变洗衣机的设置。这种情况怎么办?

方法一:停顿,等第一批衣物被烘干之前,按顺序操作,并且重复这一个过程直到找到正确的洗衣设置位置。(速度慢)但是如果计算机中停顿的话,那么效率会很慢。所以有没有其他的办法。

方法二:预测,如果你确定清洗队服的设置是正确的,就预测它可以工作,那么在等待第一批衣物被烘干的同时清洗第二批衣服。

如果预测正确,这个方法不会减慢流水线。但是如果预测错误,就需要重新洗做预测时所清洗的哪些衣服。

计算机确实采用预测来处理条件分支。一种简单的方法是总是预测条件分支指令不发生跳转。如果预测正确,流水线将全速前进。只有条件分支指令发生跳转时,流水线才会发生停顿。但是这种方案有种不好,于是衍生出了一种动态预测方法。

动态预测的一种常用实现方法是保存每条条件分支是否发生分支的历史记录,然后根据最近的过去行为来预测未来。当历史记录的数量和类型足够多时,动态分支预测的正确率过90%。当预测错误时,流水线控制必须确保预测错误的条件分支指令之后的指令执行不会生效,并且必须从正确的分支地址处重新启动流水线。

方法三:延迟决定。在洗衣例子中,每当需要做出有关洗衣的决定时,只需在等待足球队服被烘干的同时,向洗衣机中放入一批非足球队服的衣服。只要有足够多不受决定影响的脏衣服,这个方案就是可以正常工作。

在计算机中这种方法被称为延迟转移,也就是MIPS架构实际使用的解决方案。延迟转移顺序执行下一条指令,并在该指令后执行分支。由于汇编器可以自动排序指令,使用分支指令的行为达到程序员的期望,所以这个过程对MIPS汇编语言程序员来说不可见。MIPS软件会在延迟转移指令的后面放一条不受该分支影响的指令,并且发生转移的分支指令会改变这条安全指令后的指令地址。

<>2.3总结

流水线技术是一种在顺序指令流中开发指令间并行性的技术。与多处理编程相比,其优点在于它对程序员是不可见的。
那么对于结构冒险通常出现在浮点单元的周围,而浮点单元可能不是完全流水线化的。而控制冒险通常出现在定点程序中,因为其中条件分支指令出现的频率更高,也是更难预测。
数据冒险在定点和浮点程序中都可能称为性能瓶颈。

对于流水线设计者来说,指令系统既可能将事物简单化,也可能将事物复杂化。流水线设计者必须解决结构冒险、控制冒险、控制冒险和数据冒险。分支预测和前递能够在保证得到正确结果的前提下提高计算机性能。

<>3.流水线数据通路和控制

我们先看下单时钟周期的指令执行的流程,主要分为5个阶段意味着五级流水线,还意味着在任意单时钟周期里最多执行五条指令。具体的可以分成如下的5个部分。

* IF:取指令
* ID:指令译码和读寄存器堆
* EX:执行或计算地址
* MEM:数据存储器访问
* WB:写回

上面的指令是从左往右执行的,然而,在从左往右的指令流动过程中存在两个特殊情况:

* 在写回阶段,它将结果写回位于数据通路中段寄存器堆中。(会导致数据冒险)
* 在选择下一PC值时,在自增PC值与MEM阶段的分支地址之间进行选择。(会导致控制冒险)
一种表示流水先数据通路如何执行的方法是假定每一条指令都有独立的数据通路,然后将这些数据通路放在同一时间轴上来表示它们之间的关系。

三条指令需要三条数据通路,但事实上,我们可以通过引入寄存器保存数据的方式,使得部分数据通路可以在指令执行的过程中被共享。

指令存储器只在指令的五个阶段中的一个阶段被使用,而在其他四个阶段中允许被其他指令共享。为了保留在其他四个阶段的指令的值,必须把从指令存储器中读取的数据保存在寄存器中。类似的理由适用于每个流水线阶段,所以我们必须将寄存器放置在每个阶段之间的分隔线上。再回到洗衣例子中,我们会在每两个步骤之间放置一个篮子,用于存放为下一步所准备的衣服。

需要注意的是,在写回阶段的最后没有流水线寄存器。所有的指令都必须更新处理器中的某些状态,如寄存器堆、存储器或PC等,因此,单独的流水线寄存器对于已经被更新的状态来说的是多余的。

当然,每条指令都会更新PC,无论是通过自增还是通过将其设置为分支目标地址。PC可以被看作一个流水线寄存器:它给流水线的IF阶段提供数据。不同于被标记阴影的流水线寄存器,PC是可见体系结构状态的一部分。在发生例外时,PC中的内容必须被保存,而流水线寄存器中的内容则可以被丢弃。在洗衣的例子中,你可以将PC看作在清洗步骤之前盛放脏衣服的篮子。

IF和ID:一条指令在指令流水线中的第一和第二步,读写寄存时不会发生混乱,这是因为寄存器中的内容仅在时钟边沿上发生变化。尽管在阶段二中加载指令只需要寄存器1中的值,但是处理器测试并不知道当前是哪一条指令正在被译码,因此处理器将符号扩展后的16位常量以及两个寄存器中的值都存入ID/EX流水线寄存器中。我们并不一定需要全部的这三个操作,但是保留全部的三个操作数可以简化控制。

ld指令具体的五个阶段如下:

*
取指:使用PC中地址从存储器中读取指令,然后将指令放入IF/ID流水线寄存器中。PC中的地址自增4,然后写回PC,为下一时钟周期准备。这个PC值也保存在IF/ID流水线寄存器中,以备后续的指令使用(例如beq)。计算机并不知道当前正在提取的是哪一种指令,因此它必须为任何一种指令做好准备,并且将所有可能有用的信息沿流水线传递出去。
*
指令译码和读寄存器堆:IF/ID流水线寄存器的指令部分,该指令提供一个64位符号扩展的立即数字段,以及两个将要读取的寄存器编号。所以这三个值都与PC地址一起存储在ID/EX流水线寄存器中。在这里我们再次向右传递在之后的时钟周期里可能用的的所有信息。
*
执行或地址计算:加载指令从ID/EX流水线寄存器中读取一个寄存器的值和一个符号扩展的立即数,并且使用ALU部件将它们相加,它们的和存储在EX/MEM流水线寄存器中。
* 存储器访问:加载指令使用赖在EX/MEM流水寄存器中的地址读取数据存储器,并将数据存入MEM/WB流水线寄存器中。
* 写回:从MEM/WB流水线寄存器中读取数据,并将它写入图中间的寄存器堆中。
我们再来看下sd的指令,具体五个的阶段如下:

*
取指:使用PC中的地址从存储器中读取指令,然后将其放入IF/ID流水线的寄存器中。

*

指令译码和读寄存器堆:IF/ID流水线寄存器中的指令提供了用户读取寄存器的两个寄存器编号以及一个符号扩展的立即数。这三个64位的值都存储在ID/EX流水线寄存器中。

*
指令执行和地址计算:显示了指令流水中的第三步,有效地址被存放在EX/MEM流水线寄存器中。

*

存储器访问:包含要被存储的数据的寄存器在较早的流水线阶段就已经被读取并存储在ID/EX流水线寄存器中。在MEM阶段获得这个数据的唯一方法就是在EX阶段中将该数据放入EX/MEM流水线寄存器中,就像我们将有效地址存储在EX/MEM中那样。

*

写回:对存储指令来说,在写回阶段不会发生任何事情。由于存储指令之后的每一条指令都已经进入流水线中,所以我们无法加速这些指令。因此,任何指令都要进过流水线中的每一个阶段,即使它在这个阶段没有任何事情要做,因为后续指令已经按照最大速率在流水线中进行处理了。

存储指令再次说明了如果要将相关信息从之前的流水线阶段传递到后续的流水线阶段,就必须将他们放置在流水线寄存器中。否则,当下一条指令进入流水线时,该信息就会丢失。对于存储指令来说,我们需要将在ID阶段读取的寄存器信息传递到MEM阶段,然后写入存储器中。这些数据最初放置在ID/EX流水线寄存器中,之后被传递到EX/MEM流水线寄存器中

加载和存储指令还说明了第二个关键点:在流水线数据通路设计中每一个逻辑部件只能在单个流水线阶段中被使用,否则就会发生结构冒险。因此,这些部件以及对它们的控制只能与一个流水线阶段相关联。

但是我们发现上面的加载指令设计中的一个错误。在加载指令流水的WB阶段改写了哪个寄存器?更具体说,此时的寄存器号是哪条指令提供的?IF/ID流水寄存器中指令提供了写入寄存器编号。但是,
这条指令是加载指令之后的指令。

因此,我们需要在加载指令的流水线寄存器中保留目标寄存器编号。就像存储指令为了MEM阶段的使用而将寄存器值从ID/EX中传递到EX/MEM流水线寄存器中那样,加载指令需要为了WB阶段的使用而将寄存器编号从ID/EX通过EX/MEM传递到MEM/WB流水线寄存器。换一个角度来看,为了共享流水线数据通路,我们需要在IF阶段保存读取的指令,因此每个流水线寄存器都要保存当前阶段和后续阶段所需的部分指令信息。

修改后的图如下:

<>3.1流水线的图形化表示

掌握流水线技术可能会很困难,因为在每个时钟周期内同时有多条指令在一个单数据通路中执行。所以这儿我们简单的提供了两种基本的流水线图,分别是多时钟周期流水线图和单时钟周期流水线图。

我们可以先看如下的指令:
ld x10,40(x1) sub x11,x2,x3 add x12,x3,x4 ld x13,48(x1) add x14,x5,x6

单时钟周期流水线图显示了在一个单时钟周期内整个数据通路的状态,通常所有五条指令都在流水线中,被各自流水线阶段的标签锁标识。我们使用这种类型的图来表示每个时钟周期内的流水线中所发生的事情的细节。通常,这种图以组的形式出现,以显示一系列时钟周期内的流水线的操作。单时钟周期图代表在一组多时钟周期图中一个时钟周期的垂直切片,展示了流水线在指定时钟周期上每个指令对数据通路的使用情况。

<>3.2流水线控制

我们先来看如下的图:

与单周期实现的情况一样,我们假定PC在每个时钟周期被写入,因此PC没有单独的写入信号。同理,流水线寄存器(IF/ID、ID/EX、EX/MEM、MEM/WB)也没有单独的写入信号,因为流水线寄存器也在每个时钟周期写入。

为了详细说明流水线的控制,我们需要在每个流水线阶段上设置控制值。由于每条控制线都只与一个流水线阶段中功能部件相关,因此我们可以根据流水线阶段将控制线也划分成五组。

*
取值:读指令存储器和写PC的控制信号总是有效的,因此在这个阶段没有什么需要特别控制的内容。

*
指令译码/读寄存器:在RISC-V指令格式中两个源寄存器总是位于相同的位置,因此在每个阶段也没有什么需要特别控制的内容。

*
执行/地址计算:要设置的信号是ALUOp和ALUSrc,这个信号选择ALU操作,并将读数据2或或者符号扩展的立即数作为ALU的输入。

*

存储器访问:本阶段要设置的控制线是Branch、MemRead和MemWrite。这些信号分别由相等则分支、加载和存储指令设置。除非控制电路标示这是一条分支指令并且ALU的输出为0,否则将选择线性地址的下一条指令作为PCSrc信号。

*
写回:两条控制线是MemoReg和RegWrite,MemtoReg决定时将ALU结果还是将存储器值发送到寄存器堆中,RegWrite写入所选值。

由于流水线数据通路并没有改变控制线意义,因此可以使用但数据通路相同的控制值。

实现控制意味着在每条指令的每个阶段中将这七条控制线设置这些值。由于控制线从EX阶段开始,我们可以在指令译码阶段为之后的阶段创建控制信号。传递这些控制信号最简单的方式就是扩展流水线寄存器以包含这些控制信息。

<>4.写在最后

本篇博客主要简单的介绍了CPU的流水线的工作的流程,以及影响流水线的几种情况,分别是数据冒险、结构冒险、控制冒险。下篇会详细的介绍这几种冒险。

技术
©2019-2020 Toolsou All rights reserved,
TypeScript:函数类型接口8道大厂指针笔试题让你秒杀指针!!!MySQL 日期时间加减mysql 查询条件之外的数据_mysql 查询符合条件的数据查linux的操作系统版本,如何查看Linux操作系统版本?将String类型转换成Map数据类型使用uuid做MySQL主键,被老板,爆怼一顿C语言中的字符串函数和字符函数linux服务器中毒排查--基础篇C# ASCII码字符转换