指令漫游流水线CPU奇遇记
前言
本文可以看作计算机专业学生的“计算机组成原理”一课内容的总结概括。手搓CPU可谓是计算机专业学生的一大浪漫,因为在未接触这门课以前,我们总是将CPU看得非常的高大上。无论哪家科技公司,只要有芯片相关业务,他们的各种型号的处理器的命名都十分的“炫酷”。“酷睿”,“骁龙”,“锐龙”......仿佛CPU完全是一个魔法造就的神秘黑盒,但是有着无与伦比的运行速度并且牢牢占据着我们对一台电子设备的关注焦点。但是今天我们可以从底层来打开这个黑盒,发现其中的奇妙世界以及理解脉冲,信号之间协同工作,严丝合缝运转的巧妙之处。
正文
嗨!各位科技迷们!今天我们要揭秘的是计算机世界里的一颗璀璨明珠——流水线CPU!它可是决定计算机运行性能的大杀器哦!
首先,让我们穿越时空,回到上世纪60年代。那时候,计算机还比较“憨憨”,一次只能做一件事。直到人们开始意识到,如果能同时处理多个任务,那岂不是美滋滋?于是,流水线概念应运而生!
流水线CPU就像一条装配线,它把任务拆成一小块一小块,然后交给不同的处理器段来同时处理。就好像工厂里的工人,各司其职,高效无比!这么做有什么好处呢?首先,大大提高了计算机的处理速度!它能够同时进行多个任务,让我们的电脑跑得飞快!其次,它还让计算机更节约能源,不浪费一点儿嘞!
首先,我们有“取指令阶段(IF)”这个阶段就像是在超市拿购物清单一样,告诉计算机接下来要做啥。
接下来是“解码指令阶段(ID)”这一环节就是将指令翻译成计算机能听懂的语言,让它知道具体要做啥。
然后是“执行指令阶段(EX)”这相当于工人们照着清单去操作机器,将任务具体完成。
最后,我们有“写回结果阶段(WB)”这就像是把购物清单上的物品带回家一样,把结果存起来,供后续使用。
所以说,流水线CPU就像是一支充满活力的乐队,各司其职,合作无间,让我们的计算机在各种任务面前游刃有余!历经时光的洗礼,它已经成为了计算机世界的明日之星!毫不夸张地说,在现代所有类型的CPU中都占据绝对的统治地位!
接下来,让我们分阶段来更加详细的揭开流水线处理器的秘密吧!
第一节 流水线从取指令开始
晚上刚送完指令的取指单元现在很悠闲,他的工作很简单,在整个流水线中央处理器中都是有名的闲职:每天早上从PC寄存器拿到一份贴着地址的空纸箱,然后沿着线网送到不远处的指令高速缓存(ICache)仓库门口就可以了,PC是program counter的缩写,尽管这并没有准确传达其功能的含义——它的作用是指示程序执行到的位置,但是已经成为了约定俗成的名字。现在的取指单元正维持着电平不变,已经进入了梦乡。它的延迟很低,也可以说工作很少,所以可以很快上床躺平,只要明天早上时钟网络一拉高,然后经过不长的线延迟后就可以把工作完成了。只是窗外偶尔传来的忙碌加班的声音渐渐地传入了进入取值单元的梦乡,这是别的关键路径上的部门还在加班干活,但是又很快地消失了。
这里有一个定死的规矩,所有人都必须在时钟电平跳变之间进入稳定状态,也就是必须把活干完然后在第二天时钟拉起来的上升沿之前去睡觉,不得时序违例。这个规矩有多么严格呢?曾经有一个部门十分臃肿,由于招了太多人,揽了太多活,天天加班007,结果某个月的任务(指令)非常密集,导致某天晚上这个部门还在加班,一直到时钟拉起,它前面的部门的今天的快递都已经塞进大门了,但是这个违例的部门内部的电平还是没有稳定下来,导致两天的工作混在了一块。当设计师发现了刺眼的critical warning后气不打一处来,当场就把这个部门解散了。
早睡早起身体好,这是取指单元不被裁员的座右铭。
但是取指单元的岁月静好,是由于指令缓存的负重前行,我们很快就会看到这一点。
第二节 指令高速缓存
ICache,指令高速缓存部门一大早就看到了门口的贴着PC的快递盒。“这个取指单元的爷真是爷,天天除了吃就是睡,没别哒,我们部门都忙不过来了,四路仓库按索引查都没有这个PC指示的地址的指令,这下又得等好长时间从主存里调货了”。但是接到的PC不能不接,否则会丢指令。ICache派了个寄存器先存着这个指令,然后对信号传递使能的保安allowin说:"下次碰到那个老头不要接他的单,什么时候我们的指令从主存里取出来了或者我们的四路组相联的库里还有存货(指令)再允许他派单"。allowin是一个很听话的保安,第二天取指单元大爷来送贴着PC的快递盒时,allowin礼貌而坚定地拒收了,大爷只好悻悻地把空纸盒又拿回去了。
至于调货的事情,就需要ICache通过一种事先定好的协议,比如AXI协议和主存沟通了。AXI是一个非常讲究仪式而又慢性子的中介,与他沟通不仅两人要先握手寒暄一下,然后给定好数据传输的地址和长度,之后经过数十个甚至上百个周期,来自远方主存的数据才会姗姗来迟。这是一种叫做存储层次结构的深层原因所导致的,在CPU内部,只需要用寄存器,或者缓存就可以以接近时钟的频率读写数据,但是这种方法所能使用的空间有限,而且单位造价也很昂贵,于是如果使用稍慢”一点“的内存的话,虽然读写速度会降低一个或两个数量级,但是可以获得更大的存储空间。这种关系同样存在与内存和硬盘之间:速度快,空间大以及价格低是一组不可能三角,如果你想速度快,空间大,那么英特尔昂贵的傲腾存储也许能满足你,如果想要速度快,价格低,那么只有小空间的片上寄存器资源可以供你使用,否则就是速度慢但是价格低,容量大的硬盘,磁带等存储介质了。
高速缓存有两家部门,一家是在前端的指令高速缓存,另一家是在后端的数据高速缓存,他们在CPU中占有重要地位,担任着提高性能的主要责任。
流水线中央处理器的时间节奏是很快的,即使是很佛系的处理器,也是以百兆赫兹的频率快速地更替日月。而从消费级一直到企业级的英特尔或者AMD处理器的时钟频率可以达到GHZ,也就是不到一纳秒的时间。这是什么概念呢?如果一个人一秒算一次加减法的话,现代CPU可以算10亿次,而且是很保守的估计。但是与之相连的存储和外设速度却要慢若干个数量级,因此ICache取指和DCache访存如果不命中,那么就不得不花费大量时间。这个部门因此会花费大量时间并阻塞,所以增大仓库容量,增加路数是提高命中率的常规操作。
当然,如果仓库里查到PC所对应的指令的话,皆大欢喜,指令很快地就可以打包然后继续送到下一站:译码单元。
译码单元里有许许多多的选择器,这些选择器通过识别指令来标注指令类型,目标寄存器和源寄存器编号等信息。这些快递盒通过译码器去后会被分成各种类型,有ALU算数逻辑指令,有访存指令,有跳转指令,甚至有特权指令。有时候指令的长度甚至有不一样长,导致快递盒大小差异很大,比如x86指令集就是这样。这会给译码带来额外的困难,因为大小不一的快递盒可能超出了安检机的入口大小,所以不得不分几次来译码。有时译码单元会发现按照指令集手册查不到当前指令的任何信息,那么译码单元会毫不犹豫地给这个快递件贴上”指令不存在例外“的异常标签。
指令译码单元是CPU里出名的血汗工厂,里面挤着大量的选择器工人,他们的工位往往仅能看到不长的三五个比特,然后就要根据设计师定好的选择逻辑来确定指令的一部分信息。这里的工人没有时间睡觉,因为他们都是组合逻辑,所以没有寄存器可以让他们在时钟跳变间隙休息。许多工人忙到汗流浃背,彼此之间空位很小,你仿佛可以看到译码单元里产生的热气在密密麻麻的线网中逐渐地氤氲......
译码出的寄存器编号随后放在寄存器堆门口,寄存器堆顾名思义就是一堆寄存器堆在一起,不同寄存器之间有不同的编号来区分。里面存放的值一般随后用来作为执行阶段的操作数,可以进行寻址,加减,乘除,移位等操作。
执行阶段听起来很像个庞然大物,毕竟各种类型的指令需要使用大量的硬件资源来计算。但是这部分可能反而是最好实现的部分,因为各种IP和模版已经十分成熟,设计师实现某个类型的运算可能只是一行代码的工作量。
深入到硬件看,这里同样也是十分热闹。还记得之前译码器标记上的类型吗?毫无疑问,运算的类型由译码器指定,随后从寄存器堆得到的数据或者伴随指令的立即数会作为各种运算的操作数进入到运算单元。虽然在高级语言内我们可以使用诸如三角函数,指数函数,对数函数等等算术,但是CPU只会进行简单的几种基本运算。但是别担心,我们的程序可以根据各种级数展开将复杂的函数转换成CPU内部若干条基本运算的迭代循环。这些基本运算一般都包括加减,乘除,和一些位运算。
这个过程中,还有许许多多别的细节,比如除零运算我们可能认为需要停止计算并给这个指令标记上算术运算错然后后传,但是也有处理器允许计算结果是不确定的值并且不触发任何例外;还有越界时的处理,比如乘法结果太大无法用数据寄存器完整保存,那么一般就是截断数据。
这个运算单元的结果还另外有大用,不仅要正常向后传递,也需要经过前递回传到执行单元的输入选择器上。这是对于流水线的CPU,尽管早先进入的指令还没有执行结束,但是新的指令已经进入了流水线,新指令所需要的操作数可能已经被临近的旧指令修改了,但是还没有来得及写回使新指令获得最新的值,所以需要使用前递来保证结果的正确性。如果不前递会怎么样?也不会怎么样,无非就是CPU算出一个错值(问题大了)或者每有一对相邻的相关指令,就不得不等到新的值更新完毕再继续传递指令。这样看来前递还是十分重要的,但是总有一些指令的相关问题是前递都解决不了的,我们只能退而求其次,等若干个周期,宁肯慢,绝不错。
我们知道,所有程序都可以由三种基本类型的结构所实现:顺序结构,分支结构和循环结构。其中分支和循环结构就是对应指令中的分支和跳转指令。分支指令往往需要比较两个数的大小,如果满足条件,比如这个指令类似BLT(Branch less than),如果两个操作数的大小关系确实是小于的话,程序的PC就会跳转到指定的位置。这个指定的位置可能是当前的PC加上一个立即数,也可能是从一个寄存器中获得要跳转的位置。还有一些跳转是无条件的,他们可以有更大的跳转范围但是只能使用特定的寄存器。
再次提醒,我们的CPU是流水线的,指令不管分支结果怎么样已经填入了CPU,我们怎么保证分支指令后的指令是正确的呢?很遗憾,没有办法百分百地确保这一点。我们能做的就是如果错了就发一个令CPU闻之色变的信号:flush。也可以理解为清空前半段的流水线,无效掉由于分支预测失败读进来的错误指令来保护CPU的正确性,这样做的代价就是指令高速缓存可能需要重新与主存沟通,同时流水线之前的大部分工作都白费了。
等等,分支预测失败?如果我不失败,几乎次次都猜对,那不是很好吗?
确实如此,即使使用很简单的局部分支预测器,比如”吃一堑长一智“的方法,或者说只要一次预测失败就记住这个位置,下次改变跳转的采取与否选择,也可以大大提高循环体结构中的预测准确率。也有另外一种方法,也许可以称之为”吃两堑长一智“,也叫2bit饱和预测,正如字面所说,即使预测失败,还给予一次机会做出与上次相同的预测。分支预测器放置在取指单元之前,每一拍都会根据当前PC预测下一次PC,大部分情况下都是+4字节(32位处理器),也就是连续取指。
另外对于访存指令来说,同样有一个数据高速缓存来进行对接。数据高速缓存(DCache)相比与ICache,需要新增脏位标识,来指示数据不一致的情况。为什么ICache不需要呢?因为指令一般是固定的,从内存中读出来后不会被改写,所以可以认为是只读的,所以不会产生内存和缓存数据不一致的情况。除脏块的写回以外,ICache和DCache的架构就没有什么更大的区别了。读取数据同样使用地址的低位作为索引查到到对应的行,比较每一路的标签,如果命中就返回对应位置的数据。但是如果是写数据,要写的目标地址正好在缓存的块中,也还好办,但是如果不在,而且现在索引对应的几路全都”脏“了该怎么办呢?那么就需要把脏行写回到主存,然后从主存取出正确位置所在的一行,然后再在缓存中写。这样看起来与主存的沟通不仅要写还要读,显然比ICache要复杂一些,同时要花费更多的时间与主存沟通。问题不仅如此,数据段和指令段的读写都要经过AXI协议的话,究竟谁先谁后呢?这个过程还需要仲裁一下,一般是写数据优先,因为DCache位于流水线的后段,所以对应的是先执行的指令,当然要让写数据的效果对后执行的指令可见。
我们不妨再看看一类特殊的指令:特权指令。这类指令是触发例外最频繁的因素之一。特权指令有很高的权限,可以改变一般指令不能改变的状态寄存器的值和CPU的工作模式,所以如果不在特权模式下执行特权指令会报特权等级错例外。你可能发现我们上述所讲的CPU似乎除了从主存中拿出指令然后循环,计算一通写回主存后没有别的与外部交流的方式了,这与我们实际生活中能放视频,操作界面,播放音乐,打印文件的个人电脑差了很多。其实只要补上最后一片拼图:中断,就可以完成上述所有的一切;而中断怎样处理,正是由特权指令所设置的。你想要打印一串字符?把字符串的起始位置存到指定的寄存器里,把打印程序的指令起始位置设置好,然后触发系统调用例外,随后的事情就交给中断处理程序和外设吧!从这个角度看,其他的交互都只不过是执行中断程序和提供数据(比如要打印的字符,屏幕某个像素点的颜色,位置)后,实际功能外包给各种外设罢了。当然各种驱动程序怎么实现,传输速率如何的问题,那就属于程序员适配软件和工程师测试硬件的范畴了。
天下没有不散的筵席,也没有执行不完的指令。指令即使不断地被flush冲刷或者不停地被阻塞,在经过执行单元后,还是会进入到写回段。写回段需要根据指令的类型来判断其是否需要更新寄存器堆。也许你会疑问为什么不早点更新,不是不想早一些,而实在是做不到啊!
如果刚从寄存器读出来就更新的话,不好意思,数据甚至都没有算好,更新个寂寞。也许我们需要再次说明一下,在宏观上CPU执行某条指令就像是一个周期就瞬间算好了,但是微观上只有工程师才知道在时序,频率和资源权衡下,布线之后的真实CPU中信号的传递是一拍一拍的。换一个视角,宇宙间最快的速度——光速,在10GHZ的频率下,每一个时钟周期期间也只能移动 的长度,你可以想一想一块计算机CPU的边长是不是接近这个频率对应的信息传递上限,再加上内部布线的弯弯绕绕,是不是觉得现代计算机的频率已经接近到了某种不可思议的程度?
那么如果从执行单元出来就更新,这下数据都准备好了,没有理由不更新写回到寄存器堆了吧?还真有。本来执行段就有自己的任务,如果直接把数据接回去,这不是给执行单元这个部门加班了吗?别忘了我们的规矩!在时钟周期之间要把任务都执行完,如果加班要不然会造成时序违例,要不然只能降低时钟频率,而降频就意味着损失性能。幸运的是,我们并没有非这样不做的理由,或者说在执行段后寄存一级,让指令以及他们携带着的一大堆数据睡一觉,第二天再上路也是非常合理的。这里可以体现出流水线设计的精髓:找到耗时最长的单元,我们往往称之为关键路径,切一刀分成两部分,让每个单元在同一个时钟周期内做更少的事来减少延迟,是提高流水线频率的基本操作。
在写回段还有另一件重要的事,那就是异常处理。我们已经见过指令不存在例外,特权等级错例外,以及系统调用例外,但是还有一些此处未提到过的例外。处理这些例外时我们可以通过随指令一起的标签来判断例外类型。不过一般我们都需要让PC跳转到例外入口地址处去处理。例外入口地址的设置有不同的实现方法,在龙芯精简指令集中是使用特权指令来实现。入口的那一边是什么?当然就是处理例外的程序了,当这段程序处理完后,还需要回到产生例外的原来的位置继续执行。不出意外的,原来的位置需要使用特权指令来读取。我们可能发现并不容易区分异常和中断两种机制的处理流程的区别,但实际上也确实如此。他们的处理流程都是离开原来的程序流,进入到预先设置好的处理程序,执行完后再回到原来的位置,就好像在调用一段函数一样。我们也将中断分为外中断和内中断,由外设引起的中断可以叫外中断,而各种异常也叫做内中断。
到此为止,我们已经见识了一个可以作为本科生课程实验程度的简易流水线是如何工作的。如果你曾经自己实现过一个这样的处理器,烧录到FPGA上并且真正执行了一段程序时,内心会很有成就感。可能还不由得鄙夷AMD,INTEL这样的大厂,什么档次,我们实现流水线CPU,他们也实现流水线CPU。但是请保持谦逊,别忘了人家的流水线可以达到GHZ的频率,同时有大小核,多核并行的并行技术。即使只拿出人家三十年前的处理器,也已经采用了乱序超标量的技术。什么?你没有听说过乱序超标量吗?不要紧,简单理解,我们之前的讲述都只是一条一条指令在断断续续地执行,但是超标量可以同时发射两条以上的指令(尽管也可能是断断续续地)。直觉上是可以想到,如果有一串指令序列,奇数位置PC的指令与偶数位置PC的指令使用的寄存器,访存的地址都互不干扰,也不改变执行顺序,那么同时发射两条就可以很暴力地提高一倍的性能。但是会带来一系列的问题,因为显然实际情况中我们的假设并不总是成立,甚至大部分时间都不成立,那么请你想一想,如果不成立,是否有方法解决呢?至少我们总是可以用空泡(即不会产生任何有意义变化的无意义指令)来填充那些没办法同时执行的指令。即使如此简单的处理,也会带来一系列的小问题,这些问题单独拿出来都不难,但是非常繁杂,实际实现时会以你意料不到的方式不断地摧毁你的奇思妙想与天才设计。我们以上所说的都是顺序发射的处理器,那么什么是乱序呢?当然就是在不影响正确性的前提下,应发尽发,充分地利用执行单元的资源,不发白不发,发了还想发。这需要引入新的技术,比如记分板,重命名和重排序技术。对于高速缓存的设计技术,还有大量的技巧,比如很典型的使用多级缓存,比如L2 Cache甚至L3 Cache,或者使用异构混合的缓存,比如使用一种叫做Victim Cache的结构来弥补路数不足的缺陷。此外,还有指令缓存(不是ICache)可以用来放在ICache的下一段,其实就是一个先进先出的队列,可以让前端在不命中而取指令的时间后端从队列中拿出指令还有活干;或者后端阻塞的情况下前端还能源源不断的去取出指令,总之就是更充分的压榨流水线和存储的性能。
本文省略了一些重要的部件的相关内容,比如TLB(快表),因为没有区分虚地址和实地址,进而地没有介绍地址翻译模式,连带着地址翻译相关的常见异常也没有介绍。各种指令集中往往有各自独特的特色指令,虽然使用频率不高,但是却有可能对CPU的设计架构产生关键的影响,比如栅障指令,计数器读指令等等,更不用说难以归类的一些杂项指令了。
尽管CPU中有数不清的设计方法和时序优化技巧弄得人眼花缭乱,但是有几个重要的基本概念,可以说深刻影响了CPU的现状,过去以及未来。一是层次化存储结构,如果没有这种结构金字塔的存在,我们完全没有必要设计缓存,并且与之类似的各种缓存技术将毫无存在意义。二是流水线结构,从单个指令的执行过程来看,流水线并不能减少单个指令从取指到写回所需要的时间,但是在大量指令的运行过程中,由于流水线深度填充而产生的影响将被无限地抹平,看起来完全就是一条指令刚进入流水线,写回段就完成了一条指令,因此毫不夸张地说流水线是人类伟大的工程设计思想。三是没有最好的设计,只有更好的设计。小容量的Cache容易导致频繁不命中而大大降低程序运行速度,但是大容量的Cache又会占用大量资源,影响布线进而降低频率,同样会降低运行速度。选择合适容量和设计的缓存是一门艺术,也是一种实验科学。总而言之,你不会找到一种可以完美解决所有方案的设计,但是在与无穷无尽的问题的斗争中,我们设计CPU的技艺将不断精进,能够取得在有限资源下兼顾能耗和速率的更加优秀的设计,这也是计算机科学的魅力之一。
参考资料:
中国科学技术大学 王超老师 PPT,张俊霞老师 PPT
《计算机组成原理与设计》 David A. Patterson,John L.Hennessy
龙芯杯指令集手册 龙芯中科有限公司
《计算机体系结构》 龙芯中科
《超标量处理器设计》,姚永斌