计算机每执行一条指令的过程,可以分解成这样几个步骤。
-
Fetch(取得指令),也就是从PC寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令,加载到指令寄存器中,然后把PC寄存器自增,好在未来执行下一条指令。
-
Decode(指令译码),也就是根据指令寄存器里面的指令,解析成要进行什么样的操作,是R、I、J中的哪一种指令,具体要操作哪些寄存器、数据或者内存地址。
-
Execute(执行指令),也就是实际运行对应的R、I、J这些特定的指令,进行算术逻辑操作、数据传输或者直接的地址跳转。
-
重复进行1~3的步骤。
这样的步骤,其实就是一个永不停歇的“Fetch - Decode - Execute”的循环,我们把这个循环称之为指令周期(Instruction Cycle)。
在这个循环过程中,不同部分其实是由计算机中的不同组件完成的。
在取指令的阶段,我们的指令是放在存储器里的,实际上,通过PC寄存器和指令寄存器取出指令的过程,是由控制器(Control Unit)操作的。指令的解码过程,也是由控制器进行的。一旦到了执行指令阶段,无论是进行算术操作、逻辑操作的R型指令,还是进行数据传输、条件分支的I型指令,都是由算术逻辑单元(ALU)操作的,也就是由运算器处理的。不过,如果是一个简单的无条件地址跳转,那么我们可以直接在控制器里面完成,不需要用到运算器。
除了Instruction Cycle这个指令周期,在CPU里面我们还会提到另外两个常见的Cycle。一个叫MachineCycle,机器周期或者CPU周期。CPU内部的操作速度很快,但是访问内存的速度却要慢很多。每一条指令都需要从内存里面加载而来,所以我们一般把从内存里面读取一条指令的最短时间,称为CPU周期。
还有一个是我们之前提过的Clock Cycle,也就是时钟周期以及我们机器的主频。一个CPU周期,通常会由几个时钟周期累积起来。一个CPU周期的时间,就是这几个Clock Cycle的总和。
对于一个指令周期来说,我们取出一条指令,然后执行它,至少需要两个CPU周期。取出指令至少需要一个CPU周期,执行至少也需要一个CPU周期,复杂的指令则需要更多的CPU周期。
所以,我们说一个指令周期,包含多个CPU周期,而一个CPU周期包含多个时钟周期。
一般来说,我们可以认为,数据通路就是我们的处理器单元。它通常由两类原件 组成。
第一类叫操作元件,也叫组合逻辑元件(Combinational Element),其实就是我们的ALU。在前面讲ALU的过程中可以看到,它们的功能就是在特定的输入下,根据下面的组合电路的逻辑,生成特定的输出。
第二类叫存储元件,也有叫状态元件(State Element)的。比如我们在计算过程中需要用到的寄存器,无论是通用寄存器还是状态寄存器,其实都是存储元件。
我们通过数据总线的方式,把它们连接起来,就可以完成数据的存储、处理和传输了,这就是所谓的建立数据通路了。
下面我们来说控制器。它的逻辑就没那么复杂了。我们可以把它看成只是机械地重复“Fetch - Decode -Execute“循环中的前两个步骤,然后把最后一个步骤,通过控制器产生的控制信号,交给ALU去处理。
听起来是不是很简单?实际上,控制器的电路特别复杂。下面我给你详细解析一下。 一方面,所有CPU支持的指令,都会在控制器里面,被解析成不同的输出信号。我们之前说过,现在的IntelCPU支持2000个以上的指令。这意味着,控制器输出的控制信号,至少有2000种不同的组合。
运算器里的ALU和各种组合逻辑电路,可以认为是一个固定功能的电路。控制器“翻译”出来的,就是不同的控制信号。这些控制信号,告诉ALU去做不同的计算。可以说正是控制器的存在,让我们可以“编程”来实现功能,能让我们的“存储程序型计算机”名副其实。
那么,要想搭建出来整个CPU,我们需要在数字电路层面,实现这样一些功能。
首先,自然是我们之前已经讲解过的ALU了,它实际就是一个没有状态的,根据输入计算输出结果的第一个电路。
第二,我们需要有一个能够进行状态读写的电路元件,也就是我们的寄存器。我们需要有一个电路,能够存储到上一次的计算结果。这个计算结果并不一定要立刻拿到电路的下游去使用,但是可以在需要的时候拿出来用。常见的能够进行状态读写的电路,就有锁存器(Latch),以及我们后面要讲的D触发器(Data/DelayFlip-flop)的电路。
第三,我们需要有一个“自动”的电路,按照固定的周期,不停地实现PC寄存器自增,自动地去执 行“Fetch - Decode - Execute“的步骤。我们的程序执行,并不是靠人去拨动开关来执行指令的。我们希望有一个“自动”的电路,不停地去一条条执行指令。
我们看似写了各种复杂的高级程序进行各种函数调用、条件跳转。其实只是修改PC寄存器里面的地址。PC寄存器里面的地址一修改,计算机就可以加载一条指令新指令,往下运行。实际上,PC寄存器还有一个名字,就叫作程序计数器。顾名思义,就是随着时间变化,不断去数数。数的数字变大了,就去执行一条新指令。所以,我们需要的就是一个自动数数的电路。
第四,我们需要有一个“译码”的电路。无论是对于指令进行decode,还是对于拿到的内存地址去获取对应的数据或者指令,我们都需要通过一个电路找到对应的数据。这个对应的自然就是“译码器”的电路了。 好了,现在我们把这四类电路,通过各种方式组合在一起,就能最终组成功能强大的CPU了。但是,要实现这四种电路中的中间两种,我们还需要时钟电路的配合。
我们看到,要能够实现一个完整的CPU功能,除了加法器这样的电路之外,我们还需要实现其他功 能的电路。其中有一些电路,和我们实现过的加法器一样,只需要给定输入,就能得到固定的输出。这样的电路,我们称之为组合逻辑电路(Combinational Logic Circuit)。
但是,光有组合逻辑电路是不够的。你可以想一下,如果只有组合逻辑电路,我们的CPU会是什么样的?电路输入是确定的,对应的输出自然也就确定了。那么,我们要进行不同的计算,就要去手动拨动各种开关,来改变电路的开闭状态。这样的计算机,不像我们现在每天用的功能强大的电子计算机,反倒更像古老的计算尺或者机械计算机,干不了太复杂的工作,只能协助我们完成一些计算工作。这样,我们就需要引入第二类的电路,也就是时序逻辑电路(Sequential Logic Circuit)。时序逻辑电路可以帮我们解决这样几个问题。
第一个就是自动运行的问题。时序电路接通之后可以不停地开启和关闭开关,进入一个自动运行的状态。这个使得我们上一讲说的,控制器不停地让PC寄存器自增读取下一条指令成为可能。
第二个是存储的问题。通过时序电路实现的触发器,能把计算结果存储在特定的电路里面,而不是像组合逻辑电路那样,一旦输入有任何改变,对应的输出也会改变。
第三个本质上解决了各个功能按照时序协调的问题。无论是程序实现的软件指令,还是到硬件层面,各种指令的操作都有先后的顺序要求。时序电路使得不同的事件按照时间顺序发生。
想要实现时序逻辑电路,第一步我们需要的就是一个时钟。CPU的主频是由一个晶体振荡 器来实现的,而这个晶体振荡器生成的电路信号,就是我们的时钟信号。
CPU的主频是由一个晶体振荡器来实现的,而这个晶体振荡器生成的电路信号,就是我们的时钟信号。
在下面这张图里你可以看到,我们在原先一般只放一个开关的信号输入端,放上了两个开关。一个开关A,一开始是断开的,由我们手工控制;另外一个开关B,一开始是合上的,磁性线圈对准一开始就合上的开关B。
于是,一旦我们合上开关A,磁性线圈就会通电,产生磁性,开关B就会从合上变成断开。一旦这个开关断开了,电路就中断了,磁性线圈就失去了磁性。于是,开关B又会弹回到合上的状态。这样一来,电路接通,线圈又有了磁性。我们的电路就会来回不断地在开启、关闭这两个状态中切换。
这个不断切换的过程,对于下游电路来说,就是不断地产生新的0和1这样的信号。如果你在下游的电路上接上一个灯泡,就会发现这个灯泡在亮和暗之间不停切换。这个按照固定的周期不断在0和1之间切换的信号,就是我们的时钟信号(Clock Signal)。
一般这样产生的时钟信号,就像你在各种教科书图例中看到的一样,是一个振荡产生的0、1信号。
这种电路,其实就相当于把电路的输出信号作为输入信号,再回到当前电路。这样的电路构造方式呢,我们叫作反馈电路(Feedback Circuit)。
接下来,我们还会看到更多的反馈电路。上面这个反馈电路一般可以用下面这个示意图来表示,其实就是一个输出结果接回输入的反相器(Inverter),也就是我们之前讲过的非门。
有了时钟信号,我们的系统里就有了一个像“自动门”一样的开关。利用这个开关和相同的反馈电路,我们就可以构造出一个有“记忆”功能的电路。这个有记忆功能的电路,可以实现在CPU中用来存储计算结果的寄存器,也可以用来实现计算机五大组成部分之一的存储器。
我们先来看下面这个RS触发器电路。这个电路由两个或非门电路组成。我在图里面,把它标成了A和B。
-
在这个电路一开始,输入开关都是关闭的,所以或非门(NOR)A的输入是0和0。对应到我列的这个真值表,输出就是1。而或非门B的输入是0和A的输出1,对应输出就是0。B的输出0反馈到A,和之前的输入没有变化,A的输出仍然是1。而整个电路的输出Q,也就是0。
-
当我们把A前面的开关R合上的时候,A的输入变成了1和0,输出就变成了0,对应B的输入变成0和0,输出就变成了1。B的输出1反馈给到了A,A的输入变成了1和1,输出仍然是0。所以把A的开关合上之后,电路仍然是稳定的,不会像晶振那样振荡,但是整个电路的输出Q变成了1。
-
这个时候,如果我们再把A前面的开关R打开,A的输入变成和1和0,输出还是0,对应的B的输入没有变化,输出也还是1。B的输出1反馈给到了A,A的输入变成了1和0,输出仍然是0。这个时候,电路仍然稳定。开关R和S的状态和上面的第一步是一样的,但是最终的输出Q仍然是1,和第1步里Q状态是相反的。我们的输入和刚才第二步的开关状态不一样,但是输出结果仍然保留在了第2步时的输出没有发生变化。
-
这个时候,只有我们再去关闭下面的开关S,才可以看到,这个时候,B有一个输入必然是1,所以B的输出必然是0,也就是电路的最终输出Q必然是0。
这样一个电路,我们称之为触发器(Flip-Flop)。接通开关R,输出变为1,即使断开开关,输出还是1不变。接通开关S,输出变为0,即使断开开关,输出也还是0。也就是,当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个也就是我们说的记忆功能。
这里的这个电路是最简单的RS触发器,也就是所谓的复位置位触发器(Reset-Set Flip Flop) 。对应的输出结果的真值表,你可以看下面这个表格。可以看到,当两个开关都是0的时候,对应的输出不是1或者0,而是和Q的上一个状态一致。
再往这个电路里加两个与门和一个小小的时钟信号,我们就可以实现一个利用时钟信号来操作一个电路了。 这个电路可以帮我们实现什么时候可以往Q里写入数据。 我们看看下面这个电路,这个在我们的上面的R-S触发器基础之上,在R和S开关之后,加入了两个与门,同时给这两个与门加入了一个时钟信号CLK作为电路输入。
这样,当时钟信号CLK在低电平的时候,与门的输入里有一个0,两个实际的R和S后的与门的输出必然是0。也就是说,无论我们怎么按R和S的开关,根据R-S触发器的真值表,对应的Q的输出都不会发生变化。
只有当时钟信号CLK在高电平的时候,与门的一个输入是1,输出结果完全取决于R和S的开关。我们可以在这个时候,通过开关R和S,来决定对应Q的输出。
如果这个时候,我们让R和S的开关,也用一个反相器连起来,也就是通过同一个开关控制R和S。只要CLK信号是1,R和S就可以设置输出Q。而当CLK信号是0的时候,无论R和S怎么设置,输出信号Q是不变的。这样,这个电路就成了我们最常用的D型触发器。用来控制R和S这两个开关的信号呢,我们视作一个输入的数据信号D,也就是Data,这就是D型触发器的由来。
一个D型触发器,只能控制1个比特的读写,但是如果我们同时拿出多个D型触发器并列在一起,并且把用同一个CLK信号控制作为所有D型触发器的开关,这就变成了一个N位的D型触发器,也就可以同时控制N位的读写。 CPU里面的寄存器可以直接通过D型触发器来构造。我们可以在D型触发器的基础上,加上更多的开关,来实现清0或者全部置为1这样的快捷操作。
我们常说的PC寄存器,还有个名字叫程序计数器。下面我们就来看看,它为什么叫作程序计数器。
有了时钟信号,我们可以提供定时的输入;有了D型触发器,我们可以在时钟信号控制的时间点写入数据。
我们把这两个功能组合起来,就可以实现一个自动的计数器了。加法器的两个输入,一个始终设置成1,另外一个来自于一个D型触发器A。我们把加法器的输出结果,写到这个D型触发器A里面。于是,D型触发器里面的数据就会在固定的时钟信号为1的时候更新一次。
这样,我们就有了一个每过一个时钟周期,就能固定自增1的自动计数器了。这个自动计数器,可以拿来当我们的PC寄存器。事实上,PC寄存器的这个PC,英文就是Program Counter,也就是程序计数器的意思。
每次自增之后,我们可以去对应的D型触发器里面取值,这也是我们下一条需要运行指令的地址。前面第5讲我们讲过,同一个程序的指令应该要顺序地存放在内存里面。这里就和前面对应上了,顺序地存放指令,就是为了让我们通过程序计数器就能定时地不断执行新指令。
加法计数、内存取值,乃至后面的命令执行,最终其实都是由我们一开始讲的时钟信号,来控制执行时间点和先后顺序的,这也是我们需要时序电路最核心的原因。
在最简单的情况下,我们需要让每一条指令,从程序计数,到获取指令、执行指令,都在一个时钟周期内完成。如果PC寄存器自增地太快,程序就会出错。因为前一次的运算结果还没有写回到对应的寄存器里面的时候,后面一条指令已经开始读取里面的数据来做下一次计算了。这个时候,如果我们的指令使用同样的寄存器,前一条指令的计算就会没有效果,计算结果就错了。
在这种设计下,我们需要在一个时钟周期里,确保执行完一条最复杂的CPU指令,也就是耗时最长的一条CPU指令。这样的CPU设计,我们称之为单指令周期处理器(Single Cycle Processor)。
很显然,这样的设计有点儿浪费。因为即便只调用一条非常简单的指令,我们也需要等待整个时钟周期的时间走完,才能执行下一条指令。在后面章节里我们会讲到,通过流水线技术进行性能优化,可以减少需要等待的时间,这里我们暂且说到这里。
现在,我们的数据能够存储在D型触发器里了。如果我们把很多个D型触发器放在一起,就可以形成一块很大的存储空间,甚至可以当成一块内存来用。像我现在手头这台电脑,有16G内存。那我们怎么才能知道,写入和读取的数据,是在这么大的内存的哪几个比特呢?
于是,我们就需要有一个电路,来完成“寻址”的工作。这个“寻址”电路,就是我们接下来要讲的译码 器。
在现在实际使用的计算机里面,内存所使用的DRAM,并不是通过上面的D型触发器来实现的,而是使用了一种CMOS芯片来实现的。不过,这并不影响我们从基础原理方面来理解译码器。在这里,我们还是可以把内存芯片,当成是很多个连在一起的D型触发器来实现的。
如果把“寻址”这件事情退化到最简单的情况,就是在两个地址中,去选择一个地址。这样的电路,我们叫作2-1选择器。我把它的电路实现画在了这里。
我们通过一个反相器、两个与门和一个或门,就可以实现一个2-1选择器。通过控制反相器的输入是0还是1,能够决定对应的输出信号,是和地址A,还是地址B的输入信号一致。
一个反向器只能有0和1这样两个状态,所以我们只能从两个地址中选择一个。如果输入的信号有三个不同的开关,我们就能从$2^3$,也就是8个地址中选择一个了。这样的电路,我们就叫3-8译码器。
现代的计算机,如果CPU是64位的,就意味着我们的寻址空间也是$2^{64}$,那么我们就需要一个有64个开关的译码器。
所以说,其实译码器的本质,就是从输入的多个位的信号中,根据一定的开关和电路组合,选择出自己想要的信号。除了能够进行“寻址”之外,我们还可以把对应的需要运行的指令码,同样通过译码器,找出我们期望执行的指令,也就是在之前我们讲到过的opcode,以及后面对应的操作数或者寄存器地址。只是,这样的“译码器”,比起2-1选择器和3-8译码器,要复杂的多。
- 首先,我们有一个自动计数器。这个自动计数器会随着时钟主频不断地自增,来作为我们的PC寄存器。
- 在这个自动计数器的后面,我们连上一个译码器。译码器还要同时连着我们通过大量的D触发器组成的内存。
- 自动计数器会随着时钟主频不断自增,从译码器当中,找到对应的计数器所表示的内存地址,然后读取出里面的CPU指令。
- 读取出来的CPU指令会通过我们的CPU时钟的控制,写入到一个由D触发器组成的寄存器,也就是指令寄存器当中。
- 在指令寄存器后面,我们可以再跟一个译码器。这个译码器不再是用来寻址的了,而是把我们拿到的指令,解析成opcode和对应的操作数。
- 当我们拿到对应的opcode和操作数,对应的输出线路就要连接ALU,开始进行各种算术和逻辑运算。对 应的计算结果,则会再写回到D触发器组成的寄存器或者内存当中。 这样的一个完整的通路,也就完成了我们的CPU的一条指令的执行过程。在这个过程中,你会发现这样几个有意思的问题。
你现在应该知道,一条CPU指令的执行,是由“取得指令(Fetch)-指令译码(Decode)-执行指令(Execute) ”这样三个步骤组成的。这个执行过程,至少需要花费一个时钟周期。因为在取指令 的时候,我们需要通过时钟周期的信号,来决定计数器的自增。
那么,很自然地,我们希望能确保让这样一整条指令的执行,在一个时钟周期内完成。这样,我们一个时钟 周期可以执行一条指令,CPI也就是1,看起来就比执行一条指令需要多个时钟周期性能要好。采用这种设 计思路的处理器,就叫作单指令周期处理器(Single Cycle Processor),也就是在一个时钟周期内,处理器正好能处理一条指令。
不过,我们的时钟周期是固定的,但是指令的电路复杂程度是不同的,所以实际一条指令执行的时间是不同 的。在讲加法器和乘法器电路的时候,我给你看过,随着门电路层数的增加,由于门延迟的存在,位数多、计算复杂的指令需要的执行时间会更长。
不同指令的执行时间不同,但是我们需要让所有指令都在一个时钟周期内完成,那就只好把时钟周期和执行 时间最长的那个指令设成一样。这就好比学校体育课1000米考试,我们要给这场考试预留的时间,肯定得 和跑得最慢的那个同学一样。因为就算其他同学先跑完,也要等最慢的同学跑完间,我们才能进行下一项活 动。
所以,在单指令周期处理器里面,无论是执行一条用不到ALU的无条件跳转指令,还是一条计算起来电路特 别复杂的浮点数乘法运算,我们都等要等满一个时钟周期。在这个情况下,虽然CPI能够保持在1,但是我 们的时钟频率却没法太高。因为太高的话,有些复杂指令没有办法在一个时钟周期内运行完成。那么在下一个时钟周期到来,开始执行下一条指令的时候,前一条指令的执行结果可能还没有写入到寄存器里面。那下 一条指令读取的数据就是不准确的,就会出现错误。
到这里你会发现,这和我们之前讲时钟频率时候的说法不太一样。当时我们说,一个CPU时钟周期,可以认为是完成一条简单指令的时间。为什么到了这里,单指令周期处理器,反而变成了执行一条最 复杂的指令的时间呢?
这是因为,无论是PC上使用的Intel CPU,还是手机上使用的ARM CPU,都不是单指令周期处理器,而是采用了一种叫作指令流水线(Instruction Pipeline)的技术。
其实,CPU执行一条指令的过程和我们开发软件功能的过程很像。
如果我们想开发一个手机App上的功能,并不是找来一个工程师,告诉他“你把这个功能开发出来”,然后 他就吭哧吭哧把功能开发出来。真实的情况是,无论只有一个工程师,还是有一个开发团队,我们都需要先对开发功能的过程进行切分,把这个过程变成“撰写需求文档、开发后台API、开发客户端App、测试、发 布上线”这样多个独立的过程。每一个后面的步骤,都要依赖前面的步骤。
我们的指令执行过程也是一样的,它会拆分成“取指令、译码、执行”这样三大步骤。更细分一点的话,执 行的过程,其实还包含从寄存器或者内存中读取数据,通过ALU进行运算,把结果写回到寄存器或者内存 中。
如果我们有一个开发团队,我们不会让后端工程师开发完API之后,就歇着等待前台App的开发、测试乃至 发布,而是会在客户端App开发的同时,着手下一个需求的后端API开发。那么,同样的思路我们可以一样 应用在CPU执行指令的过程中。
通过前面类容,你应该已经知道了,CPU的指令执行过程,其实也是由各个电路模块组成的。我们在取指令 的时候,需要一个译码器把数据从内存里面取出来,写入到寄存器中;在指令译码的时候,我们需要另外一 个译码器,把指令解析成对应的控制信号、内存地址和数据;到了指令执行的时候,我们需要的则是一个完 成计算工作的ALU。这些都是一个一个独立的组合逻辑电路,我们可以把它们看作一个团队里面的产品经 理、后端工程师和客户端工程师,共同协作来完成任务。
这样一来,我们就不用把时钟周期设置成整条指令执行的时间,而是拆分成完成这样的一个一个小步骤需要 的时间。同时,每一个阶段的电路在完成对应的任务之后,也不需要等待整个指令执行完成,而是可以直接 执行下一条指令的对应阶段。
这就好像我们的后端程序员不需要等待功能上线,就会从产品经理手中拿到下一个需求,开始开发API。这 样的协作模式,就是我们所说的指令流水线。这里面每一个独立的步骤,我们就称之为流水线阶段或者流水 线级(Pipeline Stage)。
如果我们把一个指令拆分成“取指令-指令译码-执行指令”这样三个部分,那这就是一个三级的流水线。如 果我们进一步把“执行指令”拆分成“ALU计算(指令执行)-内存访问-数据写回”,那么它就会变成一个 五级的流水线。
五级的流水线,就表示我们在同一个时钟周期里面,同时运行五条指令的不同阶段。这个时候,虽然执行一 条指令的时钟周期变成了5,但是我们可以把CPU的主频提得更高了。我们不需要确保最复杂的那条指令在 时钟周期里面执行完成,而只要保障一个最复杂的流水线级的操作,在一个时钟周期内完成就好了。
如果某一个操作步骤的时间太长,我们就可以考虑把这个步骤,拆分成更多的步骤,让所有步骤需要执行的 时间尽量都差不多长。这样,也就可以解决我们在单指令周期处理器中遇到的,性能瓶颈来自于最复杂的指 令的问题。像我们现代的ARM或者Intel的CPU,流水线级数都已经到了14级。
虽然我们不能通过流水线,来减少单条指令执行的“延时”这个性能指标,但是,通过同时在执行多条指令 的不同阶段,我们提升了CPU的“吞吐率”。在外部看来,我们的CPU好像是“一心多用”,在同一时间, 同时执行5条不同指令的不同阶段。在CPU内部,其实它就像生产线一样,不同分工的组件不断处理上游传 递下来的内容,而不需要等待单件商品生产完成之后,再启动下一件商品的生产过程。
既然流水线可以增加我们的吞吐率,你可能要问了,为什么我们不把流水线级数做得更深呢?为什么不做成 20级,乃至40级呢?这个其实有很多原因,我在之后几讲里面会详细讲解。这里,我先讲一个最基本的原 因,就是增加流水线深度,其实是有性能成本的。
我们用来同步时钟周期的,不再是指令级别的,而是流水线阶段级别的。每一级流水线对应的输出,都要放 到流水线寄存器(Pipeline Register)里面,然后在下一个时钟周期,交给下一个流水线级去处理。所以, 每增加一级的流水线,就要多一级写入到流水线寄存器的操作。虽然流水线寄存器非常快,比如只有20皮 秒。
但是,如果我们不断加深流水线,这些操作占整个指令的执行时间的比例就会不断增加。最后,我们的性能 瓶颈就会出现在这些overhead上。如果我们指令的执行有3纳秒,也就是3000皮秒。我们需要20级的流水 线,那流水线寄存器的写入就需要花费400皮秒,占了超过10%。如果我们需要50级流水线,就要多花费1 纳秒在流水线寄存器上,占到25%。这也就意味着,单纯地增加流水线级数,不仅不能提升性能,反而会有 更多的overhead的开销。所以,设计合理的流水线级数也是现代CPU中非常重要的一点。
任何一本讲解CPU的流水线设计的教科书,都会提到流水线设计需要解决的三大冒险,分别是结构冒险 (Structural Harzard)、数据冒险(Data Harzard)以及控制冒险(Control Harzard)。
这三大冒险的名字很有意思,它们都叫作harzard(冒险)。喜欢玩游戏的话,你应该知道一个著名的游 戏,生化危机,英文名就叫Bioharzard。的确,harzard还有一个意思就是“危机”。那为什么在流水线设 计里,harzard没有翻译成“危机”,而是要叫“冒险”呢?
在CPU的流水线设计里,固然我们会遇到各种“危险”情况,使得流水线里的下一条指令不能正常运行。但 是,我们其实还是通过“抢跑”的方式,“冒险”拿到了一个提升指令吞吐率的机会。流水线架构的CPU, 是我们主动进行的冒险选择。我们期望能够通过冒险带来更高的回报,所以,这不是无奈之下的应对之举, 自然也算不上什么危机了。
事实上,对于各种冒险可能造成的问题,我们其实都准备好了应对的方案。这一讲里,我们先从结构冒险和 数据冒险说起,一起来看看这些冒险及其对应的应对方案。
结构冒险,本质上是一个硬件层面的资源竞争问题,也就是一个硬件电路层面的问题。
CPU在同一个时钟周期,同时在运行两条计算机指令的不同阶段。但是这两个不同的阶段,可能会用到同样 的硬件电路。
最典型的例子就是内存的数据访问。请你看看下面这张示意图:
可以看到,在第1条指令执行到访存(MEM)阶段的时候,流水线里的第4条指令,在执行取指令(Fetch) 的操作。访存和取指令,都要进行内存数据的读取。我们的内存,只有一个地址译码器的作为地址输入,那 就只能在一个时钟周期里面读取一条数据,没办法同时执行第1条指令的读取内存数据和第4条指令的读取 指令代码。
类似的资源冲突,其实你在日常使用计算机的时候也会遇到。最常见的就是薄膜键盘的“锁键”问题。常用 的最廉价的薄膜键盘,并不是每一个按键的背后都有一根独立的线路,而是多个键共用一个线路。如果我们 在同一时间,按下两个共用一个线路的按键,这两个按键的信号就没办法都传输出去。
这也是为什么,重度键盘用户,都要买贵一点儿的机械键盘或者电容键盘。因为这些键盘的每个按键都有独 立的传输线路,可以做到“全键无冲”,这样,无论你是要大量写文章、写程序,还是打游戏,都不会遇到 按下了键却没生效的情况。
“全键无冲”这样的资源冲突解决方案,其实本质就是增加资源。同样的方案,我们一样可以用在CPU的结 构冒险里面。对于访问内存数据和取指令的冲突,一个直观的解决方案就是把我们的内存分成两部分,让它 们各有各的地址译码器。这两部分分别是存放指令的程序内存和存放数据的数据内存。
这样把内存拆成两部分的解决方案,在计算机体系结构里叫作哈佛架构(Harvard Architecture),来自哈 佛大学设计Mark I型计算机时候的设计。对应的,我们之前说的冯·诺依曼体系结构,又叫作普林斯顿架构 (Princeton Architecture)。从这些名字里,我们可以看到,早年的计算机体系结构的设计,其实产生于 美国各个高校之间的竞争中。
不过,我们今天使用的CPU,仍然是冯·诺依曼体系结构的,并没有把内存拆成程序内存和数据内存这两部 分。因为如果那样拆的话,对程序指令和数据需要的内存空间,我们就没有办法根据实际的应用去动态分配 了。虽然解决了资源冲突的问题,但是也失去了灵活性。
不过,借鉴了哈佛结构的思路,现代的CPU虽然没有在内存层面进行对应的拆分,却在CPU内部的高速缓存 部分进行了区分,把高速缓存分成了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分。
内存的访问速度远比CPU的速度要慢,所以现代的CPU并不会直接读取主内存。它会从主内存把指令和数据 加载到高速缓存中,这样后续的访问都是访问高速缓存。而指令缓存和数据缓存的拆分,使得我们的CPU在 进行数据访问和取指令的时候,不会再发生资源冲突的问题了。
结构冒险是一个硬件层面的问题,我们可以靠增加硬件资源的方式来解决。然而还有很多冒险问题,是程序 逻辑层面的事儿。其中,最常见的就是数据冒险。
数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们可以分成三大 类,分别是先写后读(Read After Write,RAW)、先读后写(Write After Read,WAR)和写后再写 (Write After Write,WAW)。下面,我们分别看一下这几种情况。
这里有一段简单的C语言代码编译出来的汇编指令。这段代码简单地定义两个变量 a 和 b,然后计算 a = a + 2。再根据计算出来的结果,计算 b = a + 3。
int main() {
int a = 1;
int b = 2;
a = a + 2;
b = a + 3;
}
汇编代码:
int main() {
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = a + 2;
12: 83 45 fc 02 add DWORD PTR [rbp-0x4],0x2
b = a + 3;
16: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
19: 83 c0 03 add eax,0x3
1c: 89 45 f8 mov DWORD PTR [rbp-0x8],eax
}
1f: 5d pop rbp
20: c3 ret
你可以看到,在内存地址为12的机器码,我们把0x2添加到 rbp-0x4 对应的内存地址里面。然后,在紧接着 的内存地址为16的机器码,我们又要从rbp-0x4这个内存地址里面,把数据写入到eax这个寄存器里面。
所以,我们需要保证,在内存地址为16的指令读取rbp-0x4里面的值之前,内存地址12的指令写入到rbp- 0x4的操作必须完成。这就是先写后读所面临的数据依赖。如果这个顺序保证不了,我们的程序就会出错。
这个先写后读的依赖关系,我们一般被称之为数据依赖,也就是Data Dependency。
int main() {
int a = 1;
int b = 2;
a = b + a;
b = a + b;
}
汇编代码:
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = b + a;
12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
15: 01 45 fc add DWORD PTR [rbp-0x4],eax
b = a + b;
18: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1b: 01 45 f8 add DWORD PTR [rbp-0x8],eax
}
1e: 5d pop rbp
1f: c3 ret
我们同样看看对应生成的汇编代码。在内存地址为15的汇编指令里,我们要把 eax 寄存器里面的值读出 来,再加到 rbp-0x4 的内存地址里。接着在内存地址为18的汇编指令里,我们要再写入更新 eax 寄存器里 面。
如果我们在内存地址18的eax的写入先完成了,在内存地址为15的代码里面取出 eax 才发生,我们的程序计 算就会出错。这里,我们同样要保障对于eax的先读后写的操作顺序。
这个先读后写的依赖,一般被叫作反依赖,也就是Anti-Dependency。
int main() {
int a = 1;
a = 2;
}
汇编代码:
int main() {
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
a = 2;
b: c7 45 fc 02 00 00 00 mov DWORD PTR [rbp-0x4],0x2
}
在这个情况下,你会看到,内存地址4所在的指令和内存地址b所在的指令,都是将对应的数据写入到 rbp- 0x4 的内存地址里面。如果内存地址b的指令在内存地址4的指令之后写入。那么这些指令完成之后,rbp- 0x4 里的数据就是错误的。这就会导致后续需要使用这个内存地址里的数据指令,没有办法拿到正确的值。 所以,我们也需要保障内存地址4的指令的写入,在内存地址b的指令的写入之前完成。
这个写后再写的依赖,一般被叫作输出依赖,也就是Output Dependency。
除了读之后再进行读,你会发现,对于同一个寄存器或者内存地址的操作,都有明确强制的顺序要求。而这 个顺序操作的要求,也为我们使用流水线带来了很大的挑战。因为流水线架构的核心,就是在前一个指令还 没有结束的时候,后面的指令就要开始执行。
所以,我们需要有解决这些数据冒险的办法。其中最简单的一个办法,不过也是最笨的一个办法,就是流水 线停顿(Pipeline Stall),或者叫流水线冒泡(Pipeline Bubbling)。
流水线停顿的办法很容易理解。如果我们发现了后面执行的指令,会对前面执行的指令有数据层面的依赖关 系,那最简单的办法就是“再等等”。我们在进行指令译码的时候,会拿到对应指令所需要访问的寄存器和 内存地址。所以,在这个时候,我们能够判断出来,这个指令是否会触发数据冒险。如果会触发数据冒险, 我们就可以决定,让整个流水线停顿一个或者多个周期。
我在前面说过,时钟信号会不停地在0和1之前自动切换。其实,我们并没有办法真的停顿下来。流水线的 每一个操作步骤必须要干点儿事情。所以,在实践过程中,我们并不是让流水线停下来,而是在执行后面的 操作步骤前面,插入一个NOP操作,也就是执行一个其实什么都不干的操作。
这个插入的指令,就好像一个水管(Pipeline)里面,进了一个空的气泡。在水流经过的时候,没有传送水 到下一个步骤,而是给了一个什么都没有的空气泡。这也是为什么,我们的流水线停顿,又被叫作流水线冒 泡(Pipeline Bubble)的原因。
在MIPS的体系结构下,不同类型的指令,会在流水线的不同阶段进行不同的操作。
我们以MIPS的LOAD,这样从内存里读取数据到寄存器的指令为例,来仔细看看,它需要经历的5个完整的 流水线。STORE这样从寄存器往内存里写数据的指令,不需要有写回寄存器的操作,也就是没有数据写回的流水线阶段。至于像ADD和SUB这样的加减法指令,所有操作都在寄存器完成,所以没有实际的内存访问 (MEM)操作。
有些指令没有对应的流水线阶段,但是我们并不能跳过对应的阶段直接执行下一阶段。不然,如果我们先后 执行一条LOAD指令和一条ADD指令,就会发生LOAD指令的WB阶段和ADD指令的WB阶段,在同一个时钟周期发生。这样,相当于触发了一个结构冒险事件,产生了资源竞争。
所以,在实践当中,各个指令不需要的阶段,并不会直接跳过,而是会运行一次NOP操作。通过插入一个 NOP操作,我们可以使后一条指令的每一个Stage,一定不和前一条指令的同Stage在一个时钟周期执行。这样,就不会发生先后两个指令,在同一时钟周期竞争相同的资源,产生结构冒险了。
通过NOP操作进行对齐,我们在流水线里,就不会遇到资源竞争产生的结构冒险问题了。除了可以解决结构 冒险之外,这个NOP操作,也是我们之前讲的流水线停顿插入的对应操作。
但是,插入过多的NOP操作,意味着我们的CPU总是在空转,干吃饭不干活。那么,我们有没有什么办法,尽量少插入一些NOP操作呢?不要着急,下面我们就以两条先后发生的ADD指令作为例子,看看能不能找到一些好的解决方案。
add $t0, $s2,$s1
add $s2 ,$s1,$t0
这两条指令很简单。
- 第一条指令,把 s1 和 s2 寄存器里面的数据相加,存入到 t0 这个寄存器里面。
- 第二条指令,把 s1 和 t0 寄存器里面的数据相加,存入到 s2 这个寄存器里面。
因为后一条的 add 指令,依赖寄存器 t0 里的值。而 t0 里面的值,又来自于前一条指令的计算结果。所以后 一条指令,需要等待前一条指令的数据写回阶段完成之后,才能执行。就像上一讲里讲的那样,我们遇到了 一个数据依赖类型的冒险。于是,我们就不得不通过流水线停顿来解决这个冒险问题。我们要在第二条指令 的译码阶段之后,插入对应的NOP指令,直到前一天指令的数据写回完成之后,才能继续执行。
这样的方案,虽然解决了数据冒险的问题,但是也浪费了两个时钟周期。我们的第2条指令,其实就是多花 了2个时钟周期,运行了两次空转的NOP操作。
不过,其实我们第二条指令的执行,未必要等待第一条指令写回完成,才能进行。如果我们第一条指令的执 行结果,能够直接传输给第二条指令的执行阶段,作为输入,那我们的第二条指令,就不用再从寄存器里 面,把数据再单独读出来一次,才来执行代码。
我们完全可以在第一条指令的执行阶段完成之后,直接将结果数据传输给到下一条指令的ALU。然后,下一 条指令不需要再插入两个NOP阶段,就可以继续正常走到执行阶段。
这样的解决方案,我们就叫作操作数前推(Operand Forwarding),或者操作数旁路(Operand Bypassing)。其实我觉得,更合适的名字应该叫操作数转发。这里的Forward,其实就是我们写Email时 的“转发”(Forward)的意思。不过现有的经典教材的中文翻译一般都叫“前推”,我们也就不去纠正这 个说法了,你明白这个意思就好。
转发,其实是这个技术的逻辑含义,也就是在第1条指令的执行结果,直接“转发”给了第2条指令的ALU作 为输入。另外一个名字,旁路(Bypassing),则是这个技术的硬件含义。为了能够实现这里的“转发”, 我们在CPU的硬件里面,需要再单独拉一根信号传输的线路出来,使得ALU的计算结果,能够重新回到ALU 的输入里来。这样的一条线路,就是我们的“旁路”。它越过(Bypass)了写入寄存器,再从寄存器读出的过程,也为我们节省了2个时钟周期。
操作数前推的解决方案不但可以单独使用,还可以和流水线冒泡一起使用。有的时候,虽然我们可以把操作 数转发到下一条指令,但是下一条指令仍然需要停顿一个时钟周期。
比如说,我们先去执行一条LOAD指令,再去执行ADD指令。LOAD指令在访存阶段才能把数据读取出来,所以下一条指令的执行阶段,需要在访存阶段完成之后,才能进行。
总的来说,操作数前推的解决方案,比流水线停顿更进了一步。流水线停顿的方案,有点儿像游泳比赛的接 力方式。下一名运动员,需要在前一个运动员游玩了全程之后,触碰到了游泳池壁才能出发。而操作数前 推,就好像短跑接力赛。后一个运动员可以提前抢跑,而前一个运动员会多跑一段主动把交接棒传递给他。
之前我为你讲解的,无论是流水线停顿,还是操作数前推,归根到底,只要前面指令的特定阶段还没有执行 完成,后面的指令就会被“阻塞”住。
但是这个“阻塞”很多时候是没有必要的。因为尽管你的代码生成的指令是顺序的,但是如果后面的指令不 需要依赖前面指令的执行结果,完全可以不必等待前面的指令运算完成。
比如说,下面这三行代码。
a = b + c
d = a + e
x = y * z
计算里面的 x ,却要等待 a 和 d 都计算完成,实在没啥必要。所以我们完全可以在 d 的计算等待 a 的计 的过程中,先把 x 的结果给算出来。
在流水线里,后面的指令不依赖前面的指令,那就不用等待前面的指令执行,它完全可以先执行。
可以看到,因为第三条指令并不依赖于前两条指令的计算结果,所以在第二条指令等待第一条指令的访存和 写回阶段的时候,第三条指令就已经执行完成了。
这就好比你开了一家餐馆,顾客会排队来点菜。餐馆的厨房里会有洗菜、切菜、炒菜、上菜这样的各个步 骤。后厨也是按照点菜的顺序开始做菜的。但是不同的菜需要花费的时间和工序可能都有差别。有些菜做起 来特别麻烦,特别慢。比如做一道佛跳墙有好几道工序。我们没有必要非要等先点的佛跳墙上菜了,再开始 做后面的炒鸡蛋。只要有厨子空出来了,就可以先动手做前面的简单菜,先给客户端上去。
这样的解决方案,在计算机组成里面,被称为乱序执行(Out-of-Order Execution,OoOE)。乱序执行, 最早来自于著名的IBM 360。相信你一定听说过《人月神话》这本软件工程届的经典著作,它讲的就是IBM 360开发过程中的“人生体会”。而IBM 360困难的开发过程,也少不了第一次引入乱序执行这个新的CPU 技术。
那么,我们的CPU怎样才能实现乱序执行呢?是不是像玩俄罗斯方块一样,把后面的指令,找一个前面的坑 填进去就行了?事情并没有这么简单。其实,从今天软件开发的维度来思考,乱序执行好像是在指令的执行 阶段,引入了一个“线程池”。我们下面就来看一看,在CPU里,乱序执行的过程究竟是怎样的。
使用乱序执行技术后,CPU里的流水线就和我之前给你看的5级流水线不太一样了。我们一起来看一看下面 这张图。
1.在取指令和指令译码的时候,乱序执行的CPU和其他使用流水线架构的CPU是一样的。它会一级一级顺序 地进行取指令和指令译码的工作。
2.在指令译码完成之后,就不一样了。CPU不会直接进行指令执行,而是进行一次指令分发,把指令发到一 个叫作保留站(Reservation Stations)的地方。顾名思义,这个保留站,就像一个火车站一样。发送到车 站的指令,就像是一列列的火车。
3.这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。这就好像一列列的火车 都要等到乘客来齐了才能出发。4.一旦指令依赖的数据来齐了,指令就可以交到后面的功能单元(Function Unit,FU),其实就是ALU,去执行了。我们有很多功能单元可以并行运行,但是不同的功能单元能够支持执行的指令并不相同。就和我们的铁轨一样,有些从上海北上,可以到北京和哈尔滨;有些是南下的,可以到广州和深圳。
5.指令执行的阶段完成之后,我们并不能立刻把结果写回到寄存器里面去,而是把结果再存放到一个叫作重 排序缓冲区(Re-Order Buffer,ROB)的地方。
6.在重排序缓冲区里,我们的CPU会按照取指令的顺序,对指令的计算结果重新排序。只有排在前面的指令 都已经完成了,才会提交指令,完成整个指令的运算结果。
7.实际的指令的计算结果数据,并不是直接写到内存或者高速缓存里,而是先写入存储缓冲区(Store Buffer面,最终才会写入到高速缓存和内存里。
可以看到,在乱序执行的情况下,只有CPU内部指令的执行层面,可能是“乱序”的。只要我们能在指令的 译码阶段正确地分析出指令之间的数据依赖关系,这个“乱序”就只会在互相没有影响的指令之间发生。 即便指令的执行过程中是乱序的,我们在最终指令的计算结果写入到寄存器和内存之前,依然会进行一次排 序,以确保所有指令在外部看来仍然是有序完成的。
有了乱序执行,我们重新去执行上面的3行代码。
a = b + c
d = a + e
x = y * z
里面的 d 依赖于 a 的计算结果,不会在 a 的计算完成之前执行。但是我们的CPU并不会闲着,因为 x = y * z的指令同样会被分发到保留站里。因为 x 所依赖的 y 和 z 的数据是准备好的, 这里的乘法运算不会等待计 算 d,而会先去计算 x 的值。
如果我们只有一个FU能够计算乘法,那么这个FU并不会因为 d 要等待 a 的计算结果,而被闲置,而是会先 被拿去计算 x。
在 x 计算完成之后,d 也等来了 a 的计算结果。这个时候,我们的FU就会去计算出 d 的结果。然后在重排 序缓冲区里,把对应的计算结果的提交顺序,仍然设置成 a -> d -> x,而计算完成的顺序是 x -> a -> d。 在这整个过程中,整个计算乘法的FU都没有闲置,这也意味着我们的CPU的吞吐率最大化了。
整个乱序执行技术,就好像在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里 所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队 列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺 序执行。
乱序执行,极大地提高了CPU的运行效率。核心原因是,现代CPU的运行速度比访问主内存的速度要快很 多。如果完全采用顺序执行的方式,很多时间都会浪费在前面指令等待获取内存数据的时间里。CPU不得不加入NOP操作进行空转。而现代CPU的流水线级数也已经相对比较深了,到达了14级。这也意味着,同一个时钟周期内并行执行的指令数是很多的。
而乱序执行,以及我们后面要讲的高速缓存,弥补了CPU和内存之间的性能差异。同样,也充分利用了较深 的流水行带来的并发性,使得我们可以充分利用CPU的性能。
我们先来回顾一下,之前讲的cmp比较指令、jmp和jle这样的条件跳转指令。可以看到,在jmp指令发生 的时候,CPU可能会跳转去执行其他指令。jmp后的那一条指令是否应该顺序加载执行,在流水线里面进行 取指令的时候,我们没法知道。要等jmp指令执行完成,去更新了PC寄存器之后,我们才能知道,是否执行 下一条指令,还是跳转到另外一个内存地址,去取别的指令。
这种为了确保能取到正确的指令,而不得不进行等待延迟的情况,就是今天我们要讲的控制冒险(Control Harzard)。这也是流水线设计里最后一种冒险。
在遇到了控制冒险之后,我们的CPU具体会怎么应对呢?除了流水线停顿,等待前面的jmp指令执行完成之 后,再去取最新的指令,还有什么好办法吗?当然是有的。我们一起来看一看。
第一个办法,叫作缩短分支延迟。回想一下我们的条件跳转指令,条件跳转指令其实进行了两种电路操作。 第一种,是进行条件比较。这个条件比较,需要的输入是,根据指令的opcode,就能确认的条件码寄存器。 第二种,是进行实际的跳转,也就是把要跳转的地址信息写入到PC寄存器。无论是opcode,还是对应的条 件码寄存器,还是我们跳转的地址,都是在指令译码(ID)的阶段就能获得的。而对应的条件码比较的电 路,只要是简单的逻辑门电路就可以了,并不需要一个完整而复杂的ALU。
所以,我们可以将条件判断、地址跳转,都提前到指令译码阶段进行,而不需要放在指令执行阶段。对应 的,我们也要在CPU里面设计对应的旁路,在指令译码阶段,就提供对应的判断比较的电路。
这种方式,本质上和前面数据冒险的操作数前推的解决方案类似,就是在硬件电路层面,把一些计算结果更 早地反馈到流水线中。这样反馈变得更快了,后面的指令需要等待的时间就变短了。
不过只是改造硬件,并不能彻底解决问题。跳转指令的比较结果,仍然要在指令执行的时候才能知道。在流 水线里,第一条指令进行指令译码的时钟周期里,我们其实就要去取下一条指令了。这个时候,我们其实还 没有开始指令执行阶段,自然也就不知道比较的结果。
所以,这个时候,我们就引入了一个新的解决方案,叫作分支预测(Branch Prediction)技术,也就是说, 让我们的CPU来猜一猜,条件跳转后执行的指令,应该是哪一条。
最简单的分支预测技术,叫作“假装分支不发生”。顾名思义,自然就是仍然按照顺序,把指令往下执行。 其实就是CPU预测,条件跳转一定不发生。这样的预测方法,其实也是一种静态预测技术。就好像猜硬币的 时候,你一直猜正面,会有50%的正确率。
如果分支预测是正确的,我们自然赚到了。这个意味着,我们节省下来本来需要停顿下来等待的时间。如果 分支预测失败了呢?那我们就把后面已经取出指令已经执行的部分,给丢弃掉。这个丢弃的操作,在流水线 里面,叫作Zap或者Flush。CPU不仅要执行后面的指令,对于这些已经在流水线里面执行到一半的指令,我们还需要做对应的清除操作。比如,清空已经使用的寄存器里面的数据等等,这些清除操作,也有一定的开销。
所以,CPU需要提供对应的丢弃指令的功能,通过控制信号清除掉已经在流水线中执行的指令。只要对应的 清除开销不要太大,我们就是划得来的。
第三个办法,叫作动态分支预测。
上面的静态预测策略,看起来比较简单,预测的准确率也许有50%。但是如果运气不好,可能就会特别差。 于是,工程师们就开始思考,我们有没有更好的办法呢?比如,根据之前条件跳转的比较结果来预测,是不 是会更准一点?
我们日常生活里,最经常会遇到的预测就是天气预报。如果没有气象台给你天气预报,你想要猜一猜明天是 不是下雨,你会怎么办?
有一个简单的策略,就是完全根据今天的天气来猜。如果今天下雨,我们就预测明天下雨。如果今天天晴, 就预测明天也不会下雨。这是一个很符合我们日常生活经验的预测。因为一般下雨天,都是连着下几天,不 断地间隔地发生“天晴-下雨-天晴-下雨”的情况并不多见。
那么,把这样的实践拿到生活中来是不是有效呢?我在这里给了一张2019年1月上海的天气情况的表格。
我们用前一天的是不是下雨,直接来预测后一天会不会下雨。这个表格里一共有31天,那我们就可以预测 30次。你可以数一数,按照这种预测方式,我们可以预测正确23次,正确率是76.7%,比随机预测的50%要好上不少。
而同样的策略,我们一样可以放在分支预测上。这种策略,我们叫一级分支预测(One Level Branch Prediction),或者叫1比特饱和计数(1-bit saturating counter)。这个方法,其实就是用一个比特,去记 录当前分支的比较情况,直接用当前分支的比较情况,来预测下一次分支时候的比较情况。
只用一天下雨,就预测第二天下雨,这个方法还是有些“草率”,我们可以用更多的信息,而不只是一次的 分支信息来进行预测。于是,我们可以引入一个状态机(State Machine)来做这个事情。
如果连续发生下雨的情况,我们就认为更有可能下雨。之后如果只有一天放晴了,我们仍然认为会下雨。在 连续下雨之后,要连续两天放晴,我们才会认为之后会放晴。整个状态机的流转,可以参考我在文稿里放的 图。
这个状态机里,我们一共有4个状态,所以我们需要2个比特来记录对应的状态。这样这整个策略,就可以 叫作2比特饱和计数,或者叫双模态预测器(Bimodal Predictor)。
好了,现在你可以用这个策略,再去对照一下上面的天气情况。如果天气的初始状态我们放在“多半放 晴”的状态下,我们预测的结果的正确率会是22次,也就是73.3%的正确率。可以看到,并不是更复杂的算 法,效果一定就更好。实际的预测效果,和实际执行的指令高度相关。
如果想对各种分支预测技术有所了解,Wikipedia里面有更详细的内容和更多的分支预测算法,你可以看 看。
public class BranchPrediction {
public static void main(String args[]) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
for (int j = 0; j < 1000; j++) {
for (int k = 0; k < 10000; k++) {
}
}
}
long end = System.currentTimeMillis();
System.out.println("Time spent is " + (end - start));
start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
for (int j = 0; j < 1000; j++) {
for (int k = 0; k < 100; k++) {
}
}
}
end = System.currentTimeMillis();
System.out.println("Time spent is " + (end - start) + "ms");
}
}
这是一个简单的三重循环,里面没有任何逻辑代码。我们用两种不同的循环顺序各跑一次。第一次,最外重 循环循环了100次,第二重循环1000次,最内层的循环了10000次。第二次,我们把顺序倒过来,最外重循 环10000次,第二重还是1000次,最内层100次。
你可以先猜一猜,这样两次运行,花费的时间是一样的么?结果应该会让你大吃一惊。我们可以看看对应的 命令行输出。
Time spent in first loop is 5ms
Time spent in second loop is 15ms
同样循环了十亿次,第一段程序只花了5毫秒,而第二段程序则花了15毫秒,足足多了2倍。
这个差异就来自我们上面说的分支预测。我们在前面讲过,循环其实也是利用cmp和jle这样先比较后跳转的 指令来实现的。
这里的代码,每一次循环都有一个cmp和jle指令。每一个 jle 就意味着,要比较条件码寄存器的状态,决定 是顺序执行代码,还是要跳转到另外一个地址。也就是说,在每一次循环发生的时候,都会有一次“分 支”。
分支预测策略最简单的一个方式,自然是“假定分支不发生”。对应到上面的循环代码,就是循环始终会进 行下去。在这样的情况下,上面的第一段循环,也就是内层 k 循环10000次的代码。每隔10000次,才会发 生一次预测上的错误。而这样的错误,在第二层 j 的循环发生的次数,是1000次。
最外层的 i 的循环是100次。每个外层循环一次里面,都会发生1000次最内层 k 的循环的预测错误,所以一 共会发生 100 × 1000 = 10万次预测错误。
上面的第二段循环,也就是内存k的循环100次的代码,则是每100次循环,就会发生一次预测错误。这样的 错误,在第二层j的循环发生的次数,还是1000次。最外层 i 的循环是10000次,所以一共会发生 1000 × 10000 = 1000万次预测错误。
到这里,相信你能猜到为什么同样空转次数相同的循环代码,第一段代码运行的时间要少得多了。因为第一 段代码发生“分支预测”错误的情况比较少,更多的计算机指令,在流水线里顺序运行下去了,而不需要把 运行到一半的指令丢弃掉,再去重新加载新的指令执行
之前讲CPU的硬件组成的时候,我们把所有算术和逻辑运算都抽象出来,变成了一个ALU这样的“黑盒 子”。你应该还记得,关于加法器、乘法器、乃至浮点数计算的部分,其实整数的计算和 浮点数的计算过程差异还是不小的。实际上,整数和浮点数计算的电路,在CPU层面也是分开的。
一直到80386,我们的CPU都是没有专门的浮点数计算的电路的。当时的浮点数计算,都是用软件进行模拟 的。所以,在80386时代,Intel给386配了单独的387芯片,专门用来做浮点数运算。那个时候,你买386芯 片的话,会有386sx和386dx这两种芯片可以选择。386dx就是带了387浮点数计算芯片的,而sx就是不带浮 点数计算芯片的。
其实,我们现在用的Intel CPU芯片也是一样的。虽然浮点数计算已经变成CPU里的一部分,但并不是所有计算功能都在一个ALU里面,真实的情况是,我们会有多个ALU。这也是为什么,在第24讲讲乱序执行的时 候,你会看到,其实指令的执行阶段,是由很多个功能单元(FU)并行(Parallel)进行的。
不过,在指令乱序执行的过程中,我们的取指令(IF)和指令译码(ID)部分并不是并行进行的。 既然指令的执行层面可以并行进行,为什么取指令和指令译码不行呢?如果想要实现并行,该怎么办呢?
其实只要我们把取指令和指令译码,也一样通过增加硬件的方式,并行进行就好了。我们可以一次性从内存 里面取出多条指令,然后分发给多个并行的指令译码器,进行译码,然后对应交给不同的功能单元去处理。 这样,我们在一个时钟周期里,能够完成的指令就不只一条了。IPC也就能做到大于1了。
这种CPU设计,我们叫作多发射(Mulitple Issue)和超标量(Superscalar)。
什么叫多发射呢?这个词听起来很抽象,其实它意思就是说,我们同一个时间,可能会同时把多条指令发射 (Issue)到不同的译码器或者后续处理的流水线中去。
在超标量的CPU里面,有很多条并行的流水线,而不是只有一条流水线。“超标量“这个词是说,本来我们 在一个时钟周期里面,只能执行一个标量(Scalar)的运算。在多发射的情况下,我们就能够超越这个限 制,同时进行多次计算。
你可以看我画的这个超标量设计的流水线示意图。仔细看,你应该能看到一个有意思的现象,每一个功能单 元的流水线的长度是不同的。事实上,不同的功能单元的流水线长度本来就不一样。我们平时所说的14级 流水线,指的通常是进行整数计算指令的流水线长度。如果是浮点数运算,实际的流水线长度则会更长一 些。
什么是超线程技术呢?Intel想,既然CPU同时运行那些在代码层面有前后依赖关系的指令,会遇到各种冒险 问题,我们不如去找一些和这些指令完全独立,没有依赖关系的指令来运行好了。那么,这样的指令哪里来 呢?自然同时运行在另外一个程序里了。
你所用的计算机,其实同一个时间可以运行很多个程序。比如,我现在一边在浏览器里写这篇文章,后台同 样运行着一个Python脚本程序。而这两个程序,是完全相互独立的。它们两个的指令完全并行运行,而不 会产生依赖问题带来的“冒险”。
然而这个时候,你可能就会觉得奇怪了,这么做似乎不需要什么新技术呀。现在我们用的CPU都是多核的, 本来就可以用多个不同的CPU核心,去运行不同的任务。即使当时的Pentium 4是单核的,我们的计算机本 来也能同时运行多个进程,或者多个线程。这个超线程技术有什么特别的用处呢?
无论是上面说的多个CPU核心运行不同的程序,还是在单个CPU核心里面切换运行不同线程的任务,在同一时间点上,一个物理的CPU核心只会运行一个线程的指令,所以其实我们并没有真正地做到指令的并行运 行.
超线程可不是这样。超线程的CPU,其实是把一个物理层面CPU核心,“伪装”成两个逻辑层面的CPU核 心。这个CPU,会在硬件层面增加很多电路,使得我们可以在一个CPU核心内部,维护两个不同线程的指令 的状态信息。
比如,在一个物理CPU核心内部,会有双份的PC寄存器、指令寄存器乃至条件码寄存器。这样,这个CPU核心就可以维护两条并行的指令的状态。在外面看起来,似乎有两个逻辑层面的CPU在同时运行。所以,超线程技术一般也被叫作同时多线程(Simultaneous Multi-Threading,简称SMT)技术。
不过,在CPU的其他功能组件上,Intel可不会提供双份。无论是指令译码器还是ALU,一个CPU核心仍然只 有一份。因为超线程并不是真的去同时运行两个指令,那就真的变成物理多核了。超线程的目的,是在一个 线程A的指令,在流水线里停顿的时候,让另外一个线程去执行指令。因为这个时候,CPU的译码器和ALU 就空出来了,那么另外一个线程B,就可以拿来干自己需要的事情。这个线程B可没有对于线程A里面指令的 关联和依赖。
这样,CPU通过很小的代价,就能实现“同时”运行多个线程的效果。通常我们只要在CPU核心的添加10% 左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点。
不过,你也看到了,我们并没有增加真的功能单元。所以超线程只在特定的应用场景下效果比较好。一般是 在那些各个线程“等待”时间比较长的应用场景下。比如,我们需要应对很多请求的数据库应用,就很适合 使用超线程。各个指令都要等待访问内存数据,但是并不需要做太多计算。
于是,我们就可以利用好超线程。我们的CPU计算并没有跑满,但是往往当前的指令要停顿在流水线上,等 待内存里面的数据返回。这个时候,让CPU里的各个功能单元,去处理另外一个数据库连接的查询请求就是 一个很好的应用案例。
在上面的CPU信息的图里面,你会看到,中间有一组信息叫作Instructions,里面写了有MMX、SSE等等。 这些信息就是这个CPU所支持的指令集。这里的MMX和SSE的指令集,也就引出了我要给你讲的最后一个提升CPU性能的技术方案,SIMD,中文叫作单指令多数据流(Single Instruction Multiple Data)。
我们先来体会一下SIMD的性能到底怎么样。下面是两段示例程序,一段呢,是通过循环的方式,给一个list 里面的每一个数加1。另一段呢,是实现相同的功能,但是直接调用NumPy这个库的add方法。在统计两段 程序的性能的时候,我直接调用了Python里面的timeit的库
$ python
>>> import numpy as np
>>> import timeit
>>> a = list(range(1000))
>>> b = np.array(range(1000))
>>> timeit.timeit("[i + 1 for i in a]", setup="from __main__ import a", number=1000000)
32.82800309999993
>>> timeit.timeit("np.add(1, b)", setup="from __main__ import np, b", number=1000000)
0.9787889999997788
>>>
从两段程序的输出结果来看,你会发现,两个功能相同的代码性能有着巨大的差异,足足差出了30多倍。 也难怪所有用Python讲解数据科学的教程里,往往在一开始就告诉你不要使用循环,而要把所有的计算都向量化(Vectorize)。
有些同学可能会猜测,是不是因为Python是一门解释性的语言,所以这个性能差异会那么大。第一段程序的循环的每一次操作都需要Python解释器来执行,而第二段的函数调用是一次调用编译好的原生代码,所以才会那么快。如果你这么想,不妨试试直接用C语言实现一下1000个元素的数组里面的每个数加1。你会发现,即使是C语言编译出来的代码,还是远远低于NumPy。原因就是,NumPy直接用到了SIMD指令,能够并行进行向量的操作。
而前面使用循环来一步一步计算的算法呢,一般被称为SISD,也就是单指令单数据(Single InstructionSingle Data)的处理方式。如果你手头的是一个多核CPU呢,那么它同时处理多个指令的方式可以叫作MIMD,也就是多指令多数据(Multiple Instruction Multiple Dataa)。
为什么SIMD指令能快那么多呢?这是因为,SIMD在获取数据和执行指令的时候,都做到了并行。一方面,在从内存里面读取数据的时候,SIMD是一次性读取多个数据。
就以我们上面的程序为例,数组里面的每一项都是一个integer,也就是需要 4 Bytes的内存空间。Intel在引入SSE指令集的时候,在CPU里面添上了8个 128 Bits的寄存器。128 Bits也就是 16 Bytes ,也就是说,一个寄存器一次性可以加载 4 个整数。比起循环分别读取4次对应的数据,时间就省下来了。
在数据读取到了之后,在指令的执行层面,SIMD也是可以并行进行的。4个整数各自加1,互相之前完全没有依赖,也就没有冒险问题需要处理。只要CPU里有足够多的功能单元,能够同时进行这些计算,这个加法就是4路同时并行的,自然也省下了时间。
所以,对于那些在计算层面存在大量“数据并行”(Data Parallelism)的计算中,使用SIMD是一个很划算的办法。在这个大量的“数据并行”,其实通常就是实践当中的向量运算或者矩阵运算。在实际的程序开发过程中,过去通常是在进行图片、视频、音频的处理。最近几年则通常是在进行各种机器学习算法的计算。
而基于SIMD的向量计算指令,也正是在Intel发布Pentium处理器的时候,被引入的指令集。当时的指令集叫作MMX,也就是Matrix Math eXtensions的缩写,中文名字就是矩阵数学扩展。而Pentium处理器,也是CPU第一次有能力进行多媒体处理。这也正是拜SIMD和MMX所赐。
从Pentium时代开始,我们能在电脑上听MP3、看VCD了,而不用专门去买一块“声霸卡”或者“显霸 卡”了。没错,在那之前,在电脑上看VCD,是需要专门买能够解码VCD的硬件插到电脑上去的。而到了今天,通过GPU快速发展起来的深度学习技术,也一样受益于SIMD这样的指令级并行方案,在后面讲解GPU的时候,我们还会遇到它。
一提到计算机当中的异常(Exception),可能你的第一反应就是C++或者Java中的Exception。不过我们今天讲的,并不是这些软件开发过程中遇到的“软件异常”,而是和硬件、系统相关的“硬件异常”。
当然,“软件异常”和“硬件异常”并不是实际业界使用的专有名词,只是我为了方便给你说明,和C++、Java中软件抛出的Exception进行的人为区分,你明白这个意思就好。
尽管,这里我把这些硬件和系统相关的异常,叫作“硬件异常”。但是,实际上,这些异常,既有来自硬件的,也有来自软件层面的。
比如,我们在硬件层面,当加法器进行两个数相加的时候,会遇到算术溢出;或者,你在玩游戏的时候,按下键盘发送了一个信号给到CPU,CPU要去执行一个现有流程之外的指令,这也是一个“异常”。
同样,来自软件层面的,比如我们的程序进行系统调用,发起一个读文件的请求。这样应用程序向系统调用发起请求的情况,一样是通过“异常”来实现的。
关于异常,最有意思的一点就是,它其实是一个硬件和软件组合到一起的处理过程。异常的前半生,也就是异常的发生和捕捉,是在硬件层面完成的。但是异常的后半生,也就是说,异常的处理,其实是由软件来完成的。
计算机会为每一种可能会发生的异常,分配一个异常代码(Exception Number)。有些教科书会把异常代码叫作中断向量(Interrupt Vector)。异常发生的时候,通常是CPU检测到了一个特殊的信号。比如,你按下键盘上的按键,输入设备就会给CPU发一个信号。或者,正在执行的指令发生了加法溢出,同样,我们可以有一个进位溢出的信号。这些信号呢,在组成原理里面,我们一般叫作发生了一个事件(Event)。
CPU在检测到事件的时候,其实也就拿到了对应的异常代码。
这些异常代码里,I/O发出的信号的异常代码,是由操作系统来分配的,也就是由软件来设定的。而像加法溢出这样的异常代码,则是由CPU预先分配好的,也就是由硬件来分配的。这又是另一个软件和硬件共同组合来处理异常的过程。
拿到异常代码之后,CPU就会触发异常处理的流程。计算机在内存里,会保留一个异常表(ExceptionTable)。也有地方,把这个表叫作中断向量表(Interrupt Vector Table),好和上面的中断向量对应起来。这个异常表有点儿像我们讲的GOT表,存放的是不同的异常代码对应的异常处理程序(Exception Handler)所在的地址。
我们的CPU在拿到了异常码之后,会先把当前的程序执行的现场,保存到程序栈里面,然后根据异常码查询,找到对应的异常处理程序,最后把后续指令执行的指挥权,交给这个异常处理程序。
这样“检测异常,拿到异常码,再根据异常码进行查表处理”的模式,在日常开发的过程中是很常见的。
比如说,现在我们日常进行的Web或者App开发,通常都是前后端分离的。前端的应用,会向后端发起HTTP的请求。当后端遇到了异常,通常会给到前端一个对应的错误代码。前端的应用根据这个错误代码,在应用层面去进行错误处理。在不能处理的时候,它会根据错误代码向用户显示错误信息。
我在前面说了,异常可以由硬件触发,也可以由软件触发。那我们平时会碰到哪些异常呢?下面我们就一起来看看。
第一种异常叫中断(Interrupt)。顾名思义,自然就是程序在执行到一半的时候,被打断了。这个打断执行的信号,来自于CPU外部的I/O设备。你在键盘上按下一个按键,就会对应触发一个相应的信号到达CPU里面。CPU里面某个开关的值发生了变化,也就触发了一个中断类型的异常。
第二种异常叫陷阱(Trap)。陷阱,其实是我们程序员“故意“主动触发的异常。就好像你在程序里面打了一个断点,这个断点就是设下的一个"陷阱"。当程序的指令执行到这个位置的时候,就掉到了这个陷阱当中。然后,对应的异常处理程序就会来处理这个"陷阱"当中的猎物。
最常见的一类陷阱,发生在我们的应用程序调用系统调用的时候,也就是从程序的用户态切换到内核态的时候。我们在讲CPU性能的时候说过,可以用Linux下的time指令,去查看一个程序运行实际花费的时间,里面有在用户态花费的时间(user time),也有在内核态发生的时间(system time)。
我们的应用程序通过系统调用去读取文件、创建进程,其实也是通过触发一次陷阱来进行的。这是因为,我们用户态的应用程序没有权限来做这些事情,需要把对应的流程转交给有权限的异常处理程序来进行。
第三种异常叫故障(Fault)。它和陷阱的区别在于,陷阱是我们开发程序的时候刻意触发的异常,而故障通常不是。比如,我们在程序执行的过程中,进行加法计算发生了溢出,其实就是故障类型的异常。这个异常不是我们在开发的时候计划内的,也一样需要有对应的异常处理程序去处理。 故障和陷阱、中断的一个重要区别是,故障在异常程序处理完成之后,仍然回来处理当前的指令,而不是去执行程序中的下一条指令。因为当前的指令因为故障的原因并没有成功执行完成。
最后一种异常叫中止(Abort)。与其说这是一种异常类型,不如说这是故障的一种特殊情况。当CPU遇到了故障,但是恢复不过来的时候,程序就不得不中止了。
在这四种异常里,中断异常的信号来自系统外部,而不是在程序自己执行的过程中,所以我们称之为“异步”类型的异常。而陷阱、故障以及中止类型的异常,是在程序执行的过程中发生的,所以我们称之为“同步“类型的异常。
在处理异常的过程当中,无论是异步的中断,还是同步的陷阱和故障,我们都是采用同一套处理流程,也就是上面所说的,“保存现场、异常代码查询、异常处理程序调用“。而中止类型的异常,其实是在故障类型异常的一种特殊情况。当故障发生,但是我们发现没有异常处理程序能够处理这种异常的情况下,程序就不得不进入中止状态,也就是最终会退出当前的程序执行。
在实际的异常处理程序执行之前,CPU需要去做一次“保存现场”的操作。这个保存现场的操作,和我在讲解函数调用的过程非常相似。
因为切换到异常处理程序的时候,其实就好像是去调用一个异常处理函数。指令的控制权被切换到了另外一个"函数"里面,所以我们自然要把当前正在执行的指令去压栈。这样,我们才能在异常处理程序执行完成之后,重新回到当前的指令继续往下执行。
不过,切换到异常处理程序,比起函数调用,还是要更复杂一些。原因有下面几点。 第一点,因为异常情况往往发生在程序正常执行的预期之外,比如中断、故障发生的时候。所以,除了本来程序压栈要做的事情之外,我们还需要把CPU内当前运行程序用到的所有寄存器,都放到栈里面。最典型的就是条件码寄存器里面的内容。
第二点,像陷阱这样的异常,涉及程序指令在用户态和内核态之间的切换。对应压栈的时候,对应的数据是压到内核栈里,而不是程序栈里。
第三点,像故障这样的异常,在异常处理程序执行完成之后。从栈里返回出来,继续执行的不是顺序的下一条指令,而是故障发生的当前指令。因为当前指令因为故障没有正常执行成功,必须重新去执行一次。
所以,对于异常这样的处理流程,不像是顺序执行的指令间的函数调用关系。而是更像两个不同的独立进程之间在CPU层面的切换,所以这个过程我们称之为上下文切换(Context Switch)。