计算机处理器基础原理笔记

被刻印的时光 ゝ 提交于 2019-12-02 06:43:50

1. 计算机每执行一条指令的过程,可以分解成这样几个步骤。

(1)Fetch(取得指令),也就是从PC寄存器里找到对应的指令地址,根据指令地址从内存里把具体的指令,加载到指令寄存器中,然后把PC寄存器自增,在未来执行下一条指令。

(2)Decode(指令译码),也就是根据指令寄存器里面的指令,解析成要进行什么样的操作,是MIPS指令集的R、I、J中哪一种指令,具体要操作哪些寄存器、数据或者内存地址。

(3)Execute(执行指令),也就是实际运行对应的 R、I、J 这些特定的指令,进行算术逻辑操作、数据传输或者直接的地址跳转。

(4)重复进行(1)~(3)的步骤。

这其实就是一个永不停歇的“Fetch - Decode - Execute”的循环,这个循环称之为指令周期(Instruction Cycle)。

在取指令的阶段,指令是放在存储器里的,实际上,通过PC寄存器和指令寄存器取出指令的过程,是由控制器(Control Unit)操作的。指令的解码过程,也由控制器进行。到了执行指令阶段,无论是进行算术操作、逻辑操作的R型指令,还是进行数据传输、条件分支的I型指令,都是由算术逻辑单元(ALU)操作的,也就是由运算器处理的。不过,如果是一个简单的无条件地址跳转,那么我们可以直接在控制器里面完成,不需要用到运算器,如下所示:

除了指令周期,在CPU里还有另外两个常见的Cycle:

(1)一个叫Machine Cycle,即机器周期或CPU周期CPU内部的操作速度很快,但是访问内存的速度却要慢很多。每一条指令都需要从内存里面加载而来,所以一般把从内存里面读取一条指令的最短时间,称为CPU周期

(2)还有一个是Clock Cycle,即时钟周期(对应机器的主频)。一个CPU周期通常会由几个时钟周期累积起来。一个CPU周期的时间,就是几个Clock Cycle的总和。

对于一个指令周期来说,取出一条指令然后执行它,至少需要两个CPU周期。取出指令至少需要一个CPU周期,执行至少也需要一个CPU周期,复杂的指令则需要更多的CPU周期,时钟周期、机器周期和指令周期的关系如下所示:

所以,一个指令周期包含多个CPU周期,而一个CPU周期包含多个时钟周期

2. 数据通路就是处理器单元,它通常由两类原件组成。

(1)操作元件,也叫组合逻辑元件(Combinational Element),其实就是ALU,它的功能就是在特定的输入下,根据组合电路的逻辑生成特定的输出。

(2)存储元件,也叫状态元件(State Element)。比如在计算过程中需要用到的寄存器,无论是通用寄存器还是状态寄存器,其实都是存储元件。通过数据总线的方式,把组合逻辑元件和存储元件连接起来,就可以完成数据的存储、处理和传输了,这就是所谓的建立数据通路

对于控制器来说,可以把它看成只是机械地重复“Fetch - Decode – Execute”循环中的前两个步骤,然后把最后一个步骤,通过控制器产生的控制信号,交给ALU 处理。一方面,所有CPU支持的指令,都会在控制器里被解析成不同的输出信号。现在的Intel CPU支持2000个以上的指令。这意味着控制器输出的控制信号,至少有2000种不同的组合。

运算器里的ALU和各种组合逻辑电路,可以认为是一个固定功能的电路。控制器“翻译”出来的,就是不同的控制信号。这些控制信号,告诉ALU去做不同的计算。可以说正是控制器的存在,使我们可以“编程”来实现功能,如下所示:

3. 要想搭建出来整个CPU,需要以下的电路单元:

(1)首先自然是ALU,它实际就是一个没有状态的,根据输入计算输出结果的第一个电路。

(2)需要有一个能够进行状态读写的电路元件,也就是寄存器,能够存储到上一次的计算结果。这个计算结果并不一定要立刻拿到电路的下游去使用,但是可以在需要的时候拿出来用。常见的能够进行状态读写的电路,就有锁存器(Latch),以及D 触发器(Data/Delay Flip-flop)的电路。

(3)第三,需要有一个“自动”的电路,按照固定的周期,不停地实现PC寄存器自增,自动地去执行“Fetch - Decode – Execute”的步骤。我们写的各种复杂的高级程序语言进行各种函数调用、条件跳转,其实只是修改PC寄存器里面的地址。PC寄存器里面的地址一修改,计算机就可以加载一条指令新指令,往下运行。实际上,PC寄存器还有一个名字,就叫作程序计数器。顾名思义,就是随着时间变化,不断去数数。数的数字变大了,就去执行一条新指令。所以,需要的就是一个自动数数的电路。

(4)需要有一个“译码”的电路。无论是对于指令进行 decode,还是对于拿到的内存地址去获取对应的数据或者指令,都需要通过一个电路找到对应的数据。这个对应的自然就是“译码器”的电路了。现在把这四类电路,通过各种方式组合在一起,就能最终组成功能强大的 CPU 了。

4. CPU就好像一个永不停歇的机器,一直在不停地读取下一条指令去运行。那为什么CPU还会有满载运行和Idle闲置的状态呢?

因为CPU在空闲状态就会停止执行,具体来说就是切断时钟信号,CPU的主频就会瞬间降低为0,功耗也会瞬间降低为0。由于这个空闲状态是十分短暂的,所以在任务管理器里面也只会看到CPU频率下降,不会看到降低为0。当CPU从空闲状态中恢复时,就会接通时钟信号,这样CPU频率就会上升。所以会在任务管理器里面看到CPU的频率起伏变化

同时,程序计数器一直在变化,意味着满载,如果持续不变就是idle。CPU密集型任务需要CPU大量计算的任务,这个时候CPU负载就很高,IO密集型任务,CPU一直在等待IO,就会有idle

5. 要能够实现一个完整的CPU功能,除了加法器这样的电路之外,我们还需要实现其他功能的电路。其中有一些电路,和加法器一样,只需要给定输入,就能得到固定的输出。这样的电路,称之为组合逻辑电路(Combinational Logic Circuit)。

但是,光有组合逻辑电路是不够的。如果只有组合逻辑电路,电路输入是确定的,对应的输出自然也就确定了。那么,要进行不同的计算,就要去手动拨动各种开关,来改变电路的开闭状态。这样的计算机干不了太复杂的工作,只能协助完成一些计算工作。

这样就需要引入第二类的电路,也就是时序逻辑电路(Sequential Logic Circuit)。时序逻辑电路可以解决这样几个问题:

(1)自动运行的问题。时序电路接通之后可以不停地开启和关闭开关,进入一个自动运行的状态。这个使得控制器不停地让PC寄存器自增读取下一条指令成为可能。

(2)存储的问题。通过时序电路实现的触发器,能把计算结果存储在特定的电路里面,而不是像组合逻辑电路那样,一旦输入有任何改变,对应的输出也会改变。

(3)各个功能按照时序协调的问题。无论是程序实现的软件指令,还是到硬件层面,各种指令的操作都有先后的顺序要求。时序电路使得不同的事件按照时间顺序发生。

6. 要实现时序逻辑电路,首先要实现一个时钟信号。实际上时钟信号的实现,就像是一个回路接了2个开关,其中一个开关由我们控制,另一个开关由电磁线圈控制,如下所示:

开关A一开始断开,B一开始合上,当A接通时线圈产生磁性,合上的开关B就会被吸到线圈而断开,于是整个电路断开,电路断开导致电磁线圈不产生磁场,那么开关B又回去合上,再次接通电路,如此循环。这样就相当于不断有0和1的信号产生,电路的输出信号又成为电路的输入信号,这种电路叫反馈电路(Feedback Circuit)。这样的一个反馈电路,可以用一个输出结果接回输入的反相器(Inverter),即非门,如下所示:

7. 有了时钟信号,系统里就有了一个像“自动门”一样的开关。利用这个开关和相同的反馈电路,就可以构造出一个有“记忆”功能的电路,它可以实现在CPU中用来存储计算结果的寄存器,也可以用来实现计算机五大组成部分之一的存储器,例如下面的RS触发器电路,由A和B两个或非门组成,这样的电路会有以下几种情况:

(1)在这个电路一开始,输入开关都是关闭的,所以或非门(NOR)A的输入是0和0,所以输出就是 1。而或非门B的输入是0和A的输出1,对应输出就是0。B的输出0反馈到A,和之前的输入没有变化,A的输出仍然是1。而整个电路的输出Q,也就是0。

(2)当把A前面的开关R合上的时候,A的输入变成了1和0,输出就变成了0,对应B的输入变成0和0,输出就变成了1。B的输出1反馈给到了A,A的输入变成了1和1,输出仍然是0。所以把A的开关合上之后,电路仍然是稳定的,不会像晶振那样振荡,但是整个电路的输出 Q变成了1。

(3)这个时候,如果再把A前面的开关R打开,A的输入变成和1和0,输出还是0,对应的B的输入没有变化,输出也还是1。B的输出1反馈给到了A,A的输入变成了1和0,输出仍然是0。这个时候,电路仍然稳定。开关R和S的状态和上面的(1)是一样的,但是最终的输出Q仍然是 1,和第1步里Q状态是相反的。这个输入和刚才(2)的开关状态不一样,但是输出结果仍然保留在了(2)时的输出没有发生变化。

(4)这个时候,只有再去关闭下面的开关 S,才可以看到,这个时候,B有一个输入必然是1,所以B的输出必然是 0,也就是电路的最终输出Q必然是0。

这样一个电路称之为触发器(Flip-Flop)。接通开关R,输出变为1,即使断开开关,输出还是1不变。接通开关S,输出变为0,即使断开开关,输出也还是0。也就是,当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个也就是记忆功能

这里的这个电路是最简单的RS触发器,也就是所谓的复位置位触发器(Reset-Set Flip Flop) 。对应的输出结果的真值表如下所示,可以看到,当两个开关都是0的时候,对应的输出不是1或者0,而是和Q的上一个状态一致

8. 再往上述RS触发器电路里加两个与门和一个时钟信号,就可以通过一个时钟信号来操作一个电路了。这个电路可以实现什么时候可以往Q里写入数据,如下所示:

在R和S开关之后,加入了两个与门,同时给这两个与门加入了一个时钟信号CLK作为电路输入。这样,当CLK在低电平时,与门的输入里有一个0,R和S后的2个与门的输出必然是0。也就是说,无论怎么按R和S的开关,对应的Q的输出都不会发生变化。只有CLK在高电平的时候,与门的一个输入是1,输出结果完全取决于R和S的开关,此时可以通过开关R和S,来决定对应Q的输出

此时,如果让R和S的开关,也用一个反相器连起来,也就是通过同一个开关D(data的意思)控制R和S。只要CLK信号是1,R和S就可以设置输出Q。而CLK信号是0时,无论R和S怎么设置,输出信号Q是不变的。这样,这个电路就成了最常用的D型触发器,如下所示:

一个D型触发器,只能控制1个比特的读写,但是如果同时拿出多个D型触发器并列在一起,并且把用同一个CLK信号控制作为所有D型触发器的开关,就变成了一个N位的D型触发器,也就可以同时控制N位的读写。CPU里的寄存器可以直接通过D型触发器来构造。可以在D型触发器的基础上,加上更多的开关,来实现清0或者全部置为1这样的快捷操作。

因此,通过引入了时序电路,可以把数据存储下来。通过反馈电路,创建了时钟信号,然后再利用这个时钟信号和门电路组合,实现了“状态记忆”的功能。电路的输出信号不单单取决于当前的输入信号,还要取决于输出信号之前的状态。最常见的这个电路就是D触发器,它也是实际在CPU内实现存储功能的寄存器的实现方式。这也是现代计算机体系结构中的“冯·诺伊曼”机的一个关键,就是程序需要可以“存储”,而不是靠固定的线路连接或者手工拨动开关,来实现计算机的可存储和可编程的功能。

9. 有了时钟信号和触发器之后,还需要一个自动从内存读取下一条指令执行的电路。通过一个时钟信号,可以实现程序计数器,从而成为PC寄存器。然后,还需要一个能够在内存里面寻找指定数据地址的译码器,以及解析读取到的机器指令的译码器,这样就能形成一个CPU的基本组件了。

对于PC寄存器,有了时钟信号,可以提供定时的输入;有了D型触发器,可以在时钟信号控制的时间点写入数据。把这两个功能组合起来,就可以实现一个自动的计数器了。加法器的两个输入,一个始终设置成 1,另外一个来自于一个D型触发器 A。把加法器的输出结果,写到这个D型触发器A里面。于是,D型触发器里面的数据就会在固定的时钟信号为1的时候更新一次,如下所示:

这样就有了一个每过一个时钟周期,就能固定自增1的自动计数器了。这个自动计数器,可以拿来当PC寄存器。事实上,PC寄存器的这个PC,英文就是Program Counter,也就是程序计数器的意思。每次自增之后,可以去对应的D型触发器里面取值,这也是下一条需要运行指令的地址

同一个程序的指令应该要顺序地存放在内存里面。顺序地存放指令,就是为了通过程序计数器就能定时地不断执行新指令。加法计数、内存取值,乃至后面的命令执行,最终其实都是由时钟信号,来控制执行时间点和先后顺序的

在最简单的情况下,需要让每一条指令,从程序计数,到获取指令、执行指令,都在一个时钟周期内完成。但是如果PC寄存器自增地太快,程序就会出错。因为前一次的运算结果还没有写回到对应的寄存器里面的时候,后面一条指令已经开始读取里面的数据来做下一次计算了。这个时候,如果指令使用同样的寄存器,前一条指令的计算就会没有效果,计算结果就错了

如果是这种最简单的设计下,需要在一个时钟周期(clock cycle)里,确保执行完一条最复杂的CPU指令(此时clock cycle = instruction cycle),也就是耗时最长的一条CPU指令。这样的CPU设计,称之为单指令周期处理器(Single Cycle Processor)。

很显然,这样的设计有点浪费。因为即便只调用一条非常简单的指令,也需要等待整个时钟周期的时间走完,才能执行下一条指令。所以现代CPU都通过流水线技术进行性能优化,可以减少需要等待的时间。

10. 现在的内存是使用了一种CMOS芯片实现的,并非上面所说的D型触发器,此时需要一个电路,来完成对数据进行寻址的工作,例如在16G的内存空间中如何找到那几比特的数据在哪,这个就是译码器的工作。为了便于理解,依然可以用D型触发器作为例子,假装内存是多个连在一起的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译码器,要复杂的多。

11. D触发器、自动计数以及译码器,再加上ALU,就凑齐了拼装一个CPU必须要的零件了,它们的组合如下所示:

(1)首先,有一个自动计数器,会随着时钟主频不断地自增,来作为PC寄存器

(2)在这个自动计数器的后面,连上一个译码器。译码器还要同时连着通过大量的D触发器组成的内存(假装由D触发器组成)。

(3)自动计数器会随着时钟主频不断自增,从译码器当中,找到对应的计数器所表示的内存地址,然后读取出里面的CPU指令。

(4)读取出来的CPU指令会通过CPU时钟的控制,写入到一个由D触发器组成的寄存器,也就是指令寄存器当中。

(5)在指令寄存器后面,可以再跟一个译码器。这个译码器不再是用来寻址的了,而是把拿到的指令,解析成opcode和对应的操作数

(6)当拿到对应的opcode和操作数,对应的输出线路就要连接ALU,开始进行各种算术和逻辑运算。对应的计算结果,则会再写回到D触发器组成的寄存器或者内存当中。这样的一个完整的通路,也就完成了CPU的一条指令的执行过程。

因此在上述过程中,高级语言中的if…else,其实是变成了一条cmp指令和一条jmp指令。cmp指令是在进行对应的比较,比较的结果会更新到条件码寄存器当中。jmp指令则是根据条件码寄存器当中的标志位,来决定是否进行跳转以及跳转到什么地址。

那么为什么if…else会变成这样两条指令,而不是设计成一个复杂的电路,变成一条指令?到这里就可以解释了。这样分成两个指令实现,完全匹配好了在电路层面,“译码 - 执行 - 更新寄存器”这样的步骤。cmp指令的执行结果放到了条件码寄存器里面,条件跳转指令也是在ALU层面执行的,而不是在控制器里面执行的。这样的实现方式在电路层面非常直观,不需要一个非常复杂的电路,就能实现if…else的功能。

同时有了上面的过程,执行一条指令其实可以不放在一个时钟周期里面,可以直接拆分到多个时钟周期。可以在一个时钟周期里面,去自增PC寄存器的值,也就是指令对应的内存地址。然后,要根据这个地址从D触发器里面读取指令,这一步还是可以在刚才那个时钟周期内。

但是对应的指令写入到指令寄存器,可以放在一个新的时钟周期里面指令译码给到ALU之后的计算结果,要写回到寄存器,又可以放到另一个新的时钟周期。所以,执行一条计算机指令,其实可以拆分到很多个时钟周期,而不是必须使用单指令周期处理器的设计。

因为从内存里面读取指令相比从CPU缓存内读取要慢很多,所以如果使用单指令周期处理器,就意味着指令都要去等待一些慢速的操作。这些不同指令执行速度的差异,也正是计算机指令有指令周期、CPU周期和时钟周期之分的原因。因此,优化CPU的性能时,用的CPU都不是单指令周期处理器,而是通过流水线、分支预测等技术,来实现在一个指令周期里同时执行多个指令。

12. 通过自动计数器的电路,来实现一个PC寄存器,不断生成下一条要执行的计算机指令的内存地址。然后通过译码器,从内存里面读出对应的指令,写入到D触发器实现的指令寄存器中。再通过另外一个译码器,把它解析成需要执行的指令和操作数的地址。这些电路,组成了计算机五大组成部分里面的控制器

把opcode和对应的操作数,发送给ALU进行计算,得到计算结果,再写回到寄存器以及内存里面来,这个就是计算机五大组成部分里面的运算器

而CPU在执行无条件跳转如goto的时候,不需要通过运算器以及ALU,可以直接在控制器里面完成,是因为无条件跳转意味着没有计算的逻辑,是可以不经过ALU的,但是要控制器把PC寄存器设置成跳转后的指令地址。

13. 一条CPU指令的执行,是由“取得指令(Fetch)- 指令译码(Decode)- 执行指令(Execute) ”这样三个步骤组成的。这个执行过程,至少需要花费一个时钟周期。因为在取指令的时候,需要通过时钟周期的信号,来决定计数器的自增。那么,很自然地,我们希望能确保让这样一整条指令的执行,在一个时钟周期内完成。这样,一个时钟周期可以执行一条指令,CPI也就是 1,看起来就比执行一条指令需要多个时钟周期性能要好。采用这种设计思路的处理器,就叫作单指令周期处理器(Single Cycle Processor),也就是在一个时钟周期内,处理器正好能处理一条指令。

不过,虽然时钟周期是固定的,但是指令的电路复杂程度是不同的,所以实际每条指令执行的时间是不同的。随着门电路层数的增加,由于门延迟的存在,位数多、计算复杂的指令需要的执行时间会更长。不同指令的执行时间不同,但是如果需要让所有指令都在一个时钟周期内完成,就只能把时钟周期设置的和执行时间最长的那个指令一样长,如下所示:

所以,在单指令周期处理器里面,无论是执行一条用不到ALU的无条件跳转指令,还是一条计算起来电路特别复杂的浮点数乘法运算,都要等待满一个时钟周期。在这个情况下,虽然CPI能够保持在1,但是时钟频率却没法太高。因为太高的话,有些复杂指令没有办法在一个时钟周期内运行完成。那么在下一个时钟周期到来,开始执行下一条指令的时候,前一条指令的执行结果可能还没有写入到寄存器里面,那下一条指令读取的数据就是不准确的,就会出现错误。

为了减少一个clock cycle内浪费的等待时间,现代CPU都不是单指令周期处理器,并且使用了指令流水线技术(Instruction Pipeline。其实,CPU执行一条指令的过程和开发软件功能的过程很像。如果想开发一个手机App,都需要先对开发功能的过程进行切分,把这个过程变成“撰写需求文档、开发后台API、开发客户端App、测试、发布上线”这样多个独立的过程。每一个后面的步骤,都要依赖前面的步骤

指令执行过程也是一样的,它会拆分成“取指令、译码、执行”这样三大步骤。更细分一点的话,执行的过程,其实还包含从寄存器或者内存中读取数据,通过ALU进行运算,把结果写回到寄存器或者内存中。例如一个开发团队,不会让后端工程师开发完API之后,就歇着等待前台App的开发、测试乃至发布,而是会在客户端App开发的同时,着手下一个需求的后端API开发。

那么,同样的思路可以一样应用在CPU执行指令的过程中。 CPU的指令执行过程,其实也是由各个电路模块组成的。在取指令的时候,需要一个译码器把数据从内存里面取出来,写入到寄存器中;在指令译码的时候,需要另外一个译码器,把指令解析成对应的控制信号、内存地址和数据;到了指令执行的时候,需要的则是一个完成计算工作的ALU。这些都是一个一个独立的组合逻辑电路,可以把它们看作一个团队共同协作来完成任务,如下所示:

这样一来,就不用把时钟周期设置成整条指令执行的时间,而是拆分成完成这样的一个个指令中某个小步骤需要的时间。同时,每一个阶段的电路在完成对应的任务之后,也不需要等待整个指令执行完成,而是可以直接执行下一条指令的对应阶段。这就好像后端程序员不需要等待当前需求的前端功能上线,就会从产品经理手中拿到下一个需求,开始开发新需求的后端API。这样的协作模式,就是指令流水线。这里面每一个独立的步骤,就称之为流水线阶段或者流水线级(Pipeline Stage)。

如果把一个指令拆分成“取指令 - 指令译码 - 执行指令”这样三个部分,那这就是一个三级的流水线。如果进一步把“执行指令”拆分成“ALU计算(指令执行)- 内存访问 - 数据写回”,那么它就会变成一个五级的流水线。 五级的流水线,就表示在同一个时钟周期里面,同时运行五条指令各自的某个阶段。这个时候,虽然执行一条指令的clock cycle变成了5,但是可以把 CPU 的主频提得更高了。这样不需要确保最复杂的那条指令在一个时钟周期里面执行完成,而只要保障一个最复杂的流水线级的操作(即一条最复杂指令的一个最复杂步骤),在一个时钟周期内完成就好了

如果某一个操作步骤的时间太长,就可以考虑把这个步骤,拆分成更多的小步骤,让所有步骤需要执行的时间尽量都差不多长。这样,也就可以解决在单指令周期处理器中遇到的,性能瓶颈来自于最复杂的指令的问题。现代的CPU,流水线级数都已经到了14级。虽然不能通过流水线,来减少一条指令执行的“延时”这个性能指标,但是,通过同时在执行多条指令的各自某个不同阶段,提升了CPU的“吞吐率”,在外部看来,CPU好像是“一心多用”,在CPU内部,其实就像生产线一样,不同分工的组件各自不断处理上游传递下来的多商品的相同组件,而不需要等待单件商品整个生产完成之后,再启动下一件商品相同组件的生产过程。

14. 流水线提升了CPU同时执行多条指令的吞吐率,但流水线级数不能做的过长,因为增加流水线深度是有性能成本的。用来同步时钟周期的单位不再是指令级别的,而是流水线阶段级别的。每一级流水线对应的输出,都要放到流水线寄存器(Pipeline Register)里面,然后在下一个时钟周期,交给下一个流水线级去处理。所以,每增加一级的流水线,就要多一级写入到流水线寄存器的操作。虽然流水线寄存器非常快,比如只有20皮秒(即10^(−12)秒),如下所示:

但是,如果不断加深流水线,流水线寄存器写入操作占整个指令的执行时间的比例就会不断增加。最后,性能瓶颈就会出现在这些overhead上。这也就意味着,单纯地增加流水线级数,不仅不能提升性能,反而会有更多的overhead。所以,设计合理的流水线级数也是现代CPU中非常重要的一点。

因此,为了避免同一个时刻只执行一条指令顺序等待造成CPU性能浪费,通过把指令的执行过程切分成一个个流水线级,来提升CPU的吞吐率。而本身的CPU设计,又是由一个个独立的组合逻辑电路串接起来形成的,天然能够适合这样采用流水线“专业分工”的工作方式。由于每一级的overhead,所以一味地增加流水线深度,并不能无限地提高性能。同样地,因为指令的执行不再是顺序地一条条执行,而是在上一条执行到一半的时候,下一条就已经启动了,所以也给程序步骤执行的调度设计带来了很多挑战。

15. 乍看起来,流水线技术是一个提升性能的灵丹妙药。它通过把一条指令的操作切分成更细的多个步骤,可以避免CPU“浪费”。每一个细分的流水线步骤都很简单,所以单个时钟周期的时间就可以设得更短。这也变相地让 CPU 的主频提升得很快

然而,过多增加流水线深度,在同主频下,其实是降低了 CPU 的性能。因为一个Pipeline Stage,就需要一个时钟周期。假设把任务拆分成31个阶段,就需要31个时钟周期才能完成一个任务;而把任务拆分成11个阶段,就只需要11个时钟周期就能完成任务。31个Stage的3GHz主频CPU,其实和11个Stage的1GHz 频CPU性能是差不多的。事实上,因为每个Stage都需要有对应的Pipeline寄存器的开销,这个时候,更深的流水线性能可能还会更差一些

流水线技术并不能缩短单条指令的总响应时间这个性能指标,但是可以增加在同时运行很多条指令时候的吞吐率。因为不同的指令,实际执行需要的时间是不同的。例如顺序执行这样三条指令:(1)一条整数加法需要200ps。(2)一条整数乘法需要300ps。(3)一条浮点数乘法需要600ps。

如果是在单指令周期CPU上运行,最复杂的指令是一条浮点数乘法,那时钟周期就需要600ps。那这三条指令,都需要600ps。三条指令的执行时间,就需要1800ps。 如果采用的是6级流水线CPU,每一个Pipeline的Stage都只需要100ps。那么,在这三个指令的执行过程中,在指令(1)的第一个100ps的Stage结束之后,指令(2)就开始执行了。在它第一个100ps的Stage结束之后,指令(3)就开始执行了,如下所示:

这种情况下,这三条指令顺序执行所需要的总时间,就是800ps。那么在1800ps内,使用流水线的CPU比单指令周期的CPU就可以多执行一倍以上的指令数。虽然每一条指令从开始到结束拿到结果的时间并没有变化,也就是响应时间没有变化。但是同样时间内,完成的指令数增多了,也就是吞吐率上升了

16. 有些指令很简单,执行也很快,比如无条件跳转指令,不需要通过ALU进行任何计算,只要更新一下PC寄存器里面的内容就好了。而有些指令很复杂,比如浮点数的运算,需要进行指数位比较、对齐,然后对有效位进行移位,然后再进行计算。两者的执行时间相差二三十倍也很正常。所以超长流水线看起来有合理性,然而,却有两个缺点:

(1)提升流水线深度,必须要和提升CPU主频同时进行。因为在单个Pipeline Stage能够执行的功能变简单了,也就意味着单个时钟周期内能够完成的事情变少了。所以,只有降低时钟周期提升主频,CPU在指令的响应时间这个指标上才能保持和原来相同的性能。 同时,由于流水线深度的增加,需要的电路数量变多了,也就是所使用的晶体管也就变多了,主频的提升和晶体管数量的增加都使得CPU的功耗变大了。

(2) 流水线技术带来的性能提升,是一个理想情况。在实际的程序执行中,并不一定能够做得到。例如下面的代码例子:

int a = 10 + 5; // 指令 1
int b = a * 2; // 指令 2
float c = b * 1.0f; // 指令 3
int x = 10 + 5; // 指令 4
int y = a * 2; // 指令 5
float z = b * 1.0f; // 指令 6
int o = 10 + 5; // 指令 7
int p = a * 2; // 指令 8
float q = b * 1.0f; // 指令 9

可以发现指令2不能在指令1的第一个Stage执行完成之后进行。因为指令2依赖指令1的计算结果。同样的,指令3也要依赖指令2的计算结果。这样,即使采用了流水线技术,这三条指令执行完成的时间,也是200 + 300 + 600 = 1100 ps,而不是之前说的800ps。而如果指令1和2都是浮点数运算需要600ps,那这个依赖关系会导致需要的时间变成1800ps,和单指令周期CPU所要花费的时间是一样的。

这个依赖问题,就是在计算机组成里面所说的冒险(Hazard)问题。这里只列举了在数据层面的依赖,也就是数据冒险。在实际应用中,还会有结构冒险、控制冒险等其他的依赖问题。 对应这些冒险问题,也有在乱序执行、分支预测等相应的解决方案,在后续的几点中会说到。

流水线越长,这个冒险的问题就越难一解决。这是因为,同一时间同时在运行的指令太多了。如果只有3级流水线,可以把后面没有依赖关系的指令放到前面来执行,这个就是乱序执行的技术。因此可以不先执行1、2、3这三条指令,而是在流水线里,先执行1、4、7三条指令,它们之间没有依赖关系。然后再执行2、5、8以及3、6、9。这样又能够充分利用 CPU 的计算能力了。

但是,如果有20级流水线,意味着要确保这20条指令之间没有依赖关系。这个挑战一下子就变大了很多。毕竟我们平时写程序,通常前后的代码都是有一定的依赖关系的,几十条没有依赖关系的指令可不好找。这也是为什么,超长流水线的执行效率发而降低了的一个重要原因

17. 流水线技术和其他技术一样,都讲究一个“折衷”(Trade-Off)。一个合理的流水线深度,会提升CPU执行计算机指令的吞吐率。一般用IPC(Instruction Per Cycle)来衡量CPU执行指令的效率。IPC其实就是之前CPI(Cycle Per Instruction)的倒数。也就是说,IPC = 3 对应着 CPI = 0.33。过深的流水线,不仅不能提升计算机指令的吞吐率,更会加大计算的功耗和散热问题。而流水线带来的吞吐率提升,只是一个理想情况下的理论值。在实践的应用过程中,还需要解决指令之间的依赖问题,这使得超长流水线的执行效率变得很低。要想解决好冒险的依赖关系问题,需要引入乱序执行、分支预测等技术

18. 要想通过流水线技术提升执行指令的吞吐率,就需要解决三大冒险,分别是结构冒险(Structural Hazard)、数据冒险(Data Hazard)以及控制冒险(Control Hazard)。

19. 结构冒险,本质上是一个硬件层面的资源竞争问题。CPU在同一个时钟周期,同时在运行两条指令的各自不同阶段,但是这两个不同的阶段,可能会用到同样的硬件电路。例如内存的数据访问,如下所示:

可以看到,在第1条指令执行到访存(MEM)阶段的时候,流水线里的第4条指令,在执行取指令(Fetch)的操作。访存和取指令,都要进行内存数据的读取。而内存只有一个地址译码器作为地址输入,那就只能在一个时钟周期里面读取一条数据,没办法同时执行第1条指令的读取内存数据和第4条指令的读取指令代码。

类似的资源冲突,可以类比薄膜键盘的“锁键”问题。最廉价的薄膜键盘,并不是每一个按键的背后都有一根独立的线路,而是多个键共用一个线路。如果在同一时间,按下两个共用一个线路的按键,这两个按键的信号就没办法都传输出去。而贵一点儿的机械键盘或者电容键盘,每个按键都有独立的传输线路,可以做到“全键无冲”。“全键无冲”这样的资源冲突解决方案,其实本质就是增加资源。在CPU的结构冒险里面。对于访问内存数据和取指令的冲突,一个直观的解决方案就是把内存分成两部分,让它们各有各的地址译码器,分别是存放指令的程序内存和存放数据的数据内存。

这样把内存拆成两部分的解决方案,在计算机体系结构里叫作哈佛架构(Harvard Architecture)。对应的冯·诺依曼体系结构又叫作普林斯顿架构(Princeton Architecture)。不过,今天的CPU仍然是冯·诺依曼体系结构,并没有把内存拆成程序内存和数据内存这两部分。因为如果那样拆的话,对程序指令和数据需要的内存空间,就没有办法根据实际的应用去动态分配了。虽然解决了资源冲突的问题,但是也失去了灵活性,如下所示:

不过,借鉴了哈佛结构的思路,现代CPU虽然没有在内存层面进行对应的拆分,却CPU内部的高速缓存部分进行了区分,把高速缓存分成了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分。内存的访问速度远比CPU的速度要慢,所以现CPU并不会直接读取主内存。它会从主内存把指令和数据加载到CPU内的高速缓存中,这样后续的访问都是访问高速缓存。而指令缓存和数据缓存的拆分,使得CPU在进行数据访问和取指令的时候,不会再发生资源冲突的问题了。

20.结构冒险是一个硬件层面的问题,可以靠增加硬件资源的方式来解决。然而还有很多冒险问题,是程序逻辑层面上的。其中,最常见的就是数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,可以分成三大类,分别是先写后读(Read After Write,RAW)、先读后写(Write After Read,WAR)和写后再写(Write After Write,WAW),也叫数据依赖、反依赖和输出依赖

(1)对于先写后读,有以下的代码例子:

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)。

(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
   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)。

(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
  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)的原因。不过,流水线停顿这样的解决方案,是以牺牲CPU性能为代价的。因为实际上在最差的情况下,流水线架构的CPU又会退化成单指令周期的CPU了。

21. 在流水线技术三大冒险里的结构冒险和数据冒险中,针对前者的改进方案是增加资源,通过添加指令缓存和数据缓存,让对于指令和数据的访问可以同时进行,这个办法帮助CPU解决了取指令和访问数据之间的资源冲突。针对后者的一个改进方案是直接进行等待,通过插入NOP这样的无效指令,等待之前的指令完成,这样我们就能解决不同指令之间的数据依赖问题。

然而,除了加硬件电路和直接等待这两种笨办法,还有不需要加电路与增加等待时间的精巧方案,叫做操作数前推。以五级流水线“取指令(IF)- 指令译码(ID)- 指令执行(EX)- 内存访问(MEM)- 数据写回(WB)”和MIPS结构中的LOAD这样从内存里读取数据到寄存器的指令为例,来看看它需要经历的5个完整的流水线。STORE这样从寄存器往内存里写数据的指令,不需要有写回寄存器的操作,也就是没有数据写回的流水线阶段。至于像ADD和SUB这样的加减法指令,所有操作都在寄存器完成,所以没有实际的内存访问(MEM)操作,如下所示:

有些指令没有某个流水线阶段,但是时间上并不能跳过对应的阶段直接执行下一阶段。例如,如果先后执行一条LOAD指令和一条ADD指令,就会发生LOAD指令的WB阶段和ADD指令的WB阶段,在同一个时钟周期发生。这样,相当于触发了一个结构冒险事件,产生了资源竞争,如下所示:

所以,在CPU中各个指令不需要的阶段,并不会直接跳过,而是会运行一次NOP操作。这样可以使后一条指令的每一个Stage,一定不和前一条指令的同Stage在一个时钟周期执行。这样,就不会发生先后两个指令在同一时钟周期竞争相同的资源,产生结构冒险了,如下所示:

22. 通过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),节省了2个时钟周期,如下所示:

操作数前推还可以和流水线冒泡一起使用。有的时候,虽然可以把操作数转发到下一条指令,但是下一条指令仍然需要停顿一个时钟周期。比如说,先去执行一条LOAD指令,再去执行ADD指令。LOAD指令在访存阶段才能把数据读取出来,所以下一条指令ADD的执行阶段,需要在LOAD指令访存阶段完成之后,才能进行:

操作数前推的解决方案,比流水线停顿更进了一步。流水线停顿的方案,就像游泳比赛的接力方式,下一名运动员需要在前一个运动员游完全程之后才能出发。而操作数前推,就像短跑接力赛,后一个运动员可以提前抢跑,而前一个运动员会多跑一段主动把交接棒传递给他。操作数前推,就是通过在硬件层面制造一条旁路,让一条指令的计算结果,可以直接传输给下一条指令,而不再需要“指令 1 写回寄存器,指令 2 再读取寄存器“这样多此一举的操作。这样直接传输带来的好处就是,后面的指令可以减少,甚至消除原本需要通过流水线停顿才能解决的数据冒险问题。有些时候,操作数前推并不能减少所有“冒泡”,只能去掉其中的一部分,所以仍然需要通过插入一些“气泡”来解决冒险问题。

23. 有了操作数前推,仍然少不了要插入很多NOP的“气泡”。CPU还可以通过乱序执行,进一步减少“气泡”。在乱序执行技术应用之前,对于结构冒险,由于限制来自于同一时钟周期不同的指令,要访问相同的硬件资源,解决方案是增加资源。对于数据冒险,由于限制来自于数据之间的各种依赖,可以提前把数据转发到下一个指令。 但是即便综合运用增加资源与操作数前推,仍然会遇到不得不停下整个流水线,等待前面的指令完成的情况,也就是采用流水线停顿的解决方案。

那能不能让后面没有数据依赖的指令,在前面指令停顿的时候先执行呢?答案当然是可以的。毕竟,流水线停顿的时候,对应的电路闲着也是闲着。无论是流水线停顿,还是操作数前推,归根到底,只要前面指令的特定阶段还没有执行完成,后面的指令就会被“阻塞”住。 但是这个“阻塞”很多时候是没有必要的。因为尽管代码生成的指令是顺序的,但是如果后面的指令不需要依赖前面指令的执行结果,完全可以不必等待前面的指令运算完成。例如下面的代码:

a = b + c
d = a * e
x = y * z

计算x却要等待a和d都计算完成,实在没有必要。所以完全可以在d的计算等待a的计算的过程中,先把x的结果给算出来。在流水线里,后面的指令不依赖前面的指令,那就不用等待前面的指令执行,它完全可以先执行。可以看到,因为第三条指令并不依赖于前两条指令的计算结果,所以在第二条指令等待第一条指令的访存和写回阶段的时候,第三条指令就已经执行完成了。这样的解决方案,被称为乱序执行(Out-of-Order Execution,OoOE)。如下所示:

24. 乱序执行在CPU中的实现并不简单,从软件开发的角度看,乱序执行就像是在指令的执行阶段,引入了一个“线程池”。不同于上面“取指令(IF)- 指令译码(ID)- 指令执行(EX)- 内存访问(MEM)- 数据写回(WB)”的传统五级流水线,有了乱序执行后,流水线的步骤会变成下面的样子:

(1)在取指令和指令译码的时候,乱序执行的CPU和其他使用流水线架构的CPU是一样的。它会一级一级顺序地进行取指令和指令译码的工作。

(2)在指令译码完成之后,就不一样了。CPU不会直接进行指令执行,而是进行一次指令分发,把指令发到一个叫作保留站(Reservation Stations)的地方。顾名思义,这个保留站,就像一个火车站一样。发送到车站的指令,就像是一列列的火车。

(3)这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。这就好像一列列的火车都要等到乘客来齐了才能出发。

(4)一旦指令依赖的数据来齐了,指令就可以交到后面的功能单元(Function Unit,FU),其实就是ALU,去执行了。很多功能单元可以并行运行,但是不同的功能单元能够支持执行的指令并不相同。就像不同去向的铁轨一样。

(5)指令执行的阶段完成之后,并不能立刻把结果写回到寄存器里面去,而是把结果再存放到一个叫作重排序缓冲区(Re-Order Buffer,ROB)的地方。

(6)在重排序缓冲区里,CPU会按照取指令的顺序,对指令的计算结果重新排序。只有排在前面的指令都已经完成了,才会提交指令,完成整个指令的运算结果

(7)实际的指令的计算结果数据,并不是直接写到内存或者高速缓存里,而是先写入存储缓冲区(Store Buffer),最终才会写入到高速缓存和内存里。

可以看到,在乱序执行的情况下,只有CPU内部指令的执行层面可能是“乱序”的。只要能在指令的译码阶段正确地分析出指令之间的数据依赖关系,这个“乱序”就只会在互相没有影响的指令之间发生。即便指令的执行过程中是乱序的,在最终指令的计算结果写入到寄存器和内存之前依然会进行一次排序,以确保所有指令在外部看来仍然是有序完成的。

有了乱序执行,在执行上面第23点中的三行代码时,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的吞吐率最大化了。

25. 乱序执行技术解决了流水线阻塞的问题,就好像在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行

乱序执行极大地提高了CPU的运行效率,核心原因是现代CPU的运行速度比访问主内存的速度要快很多,如果完全采用顺序执行的方式,很多时间都会浪费在前面指令等待获取内存数据的时间里,CPU不得不加入NOP操作进行空转。而现代CPU的流水线级数也已经到达14级,这意味着同一个时钟周期内并行执行的指令数是很多的。而乱序执行以及高速缓存,弥补了CPU和内存之间的性能差异同样也充分利用了较深的流水线带来的并发性,可以充分利用 CPU 的性能。在乱序执行的过程中,只有指令的执行阶段是乱序的,后面的内存访问和数据写回阶段都仍然是顺序的。这种保障内存数据访问顺序的模型,叫作强内存模型(Strong Memory Model)。

26. 在结构冒险和数据冒险中,所有的流水线停顿操作都是从指令执行阶段开始。流水线的前两个阶段,即取指令(IF)和指令译码(ID)的阶段,是不需要停顿的。CPU会在流水线里直接去取下一条指令,然后进行译码。而取指令和指令译码不会遇到任何停顿需要一个前提,就是所有的指令代码都得是顺序加载执行的。如果一旦遇到if…else这样的条件分支,或者for/while 循环,这种不停顿就会不成立。

在jmp指令发生的时候,CPU可能会跳转去执行其他指令。jmp后的那一条指令是否应该顺序加载执行,在流水线里面进行取指令的时候,是没法知道的。要等jmp指令执行完成,去更新了PC寄存器之后才能知道,是执行下一条指令还是跳转到另外一个内存地址,去取别的指令。这种为了确保能取到正确的指令,而不得不进行等待延迟的情况,就是控制冒险(Control Harzard)。这也是流水线设计里除结构冒险数据冒险以外最后一种冒险。

27. 在遇到了控制冒险之后,CPU除了流水线停顿,等待前面的jmp指令执行完成之后再去取最新的指令,还有什么好办法吗?总共有三种办法:

(1)缩短分支延迟。条件跳转指令其实进行了两种电路操作,第一种是进行条件比较,这个条件比较,需要的输入是根据指令的opcode,就能确认的条件码寄存器。第二种是进行实际的跳转,也就是把要跳转的地址信息写入到PC寄存器。无论是opcode,还是对应的条件码寄存器或跳转的地址,都是在指令译码(ID)的阶段就能获得的。而对应的条件码比较的电路,只要是简单的逻辑门电路就可以了,并不需要一个完整而复杂的ALU。

所以,可以将条件判断、地址跳转都提前到指令译码阶段进行,而不需要放在指令执行阶段。对应的也要在CPU里面设计对应的旁路,在指令译码阶段,就提供对应的判断比较的电路。这种方式,本质上和前面数据冒险的操作数前推类似,就是在硬件电路层面,把一些计算结果更早地反馈到流水线中。这样反馈变得更快了,后面的指令需要等待的时间就变短了

不过只是改造硬件,并不能彻底解决问题。跳转指令的比较结果,仍然要在指令执行的时候才能知道。在流水线里,第一条指令进行指令译码的时钟周期里,其实就要去取下一条指令了。这个时候其实还没有开始指令执行阶段,自然也就不知道比较的结果。

(2)静态分支预测(Branch Prediction)技术。也就是让CPU猜一猜条件跳转后执行的指令,应该是哪一条。最简单的分支预测技术就是CPU预测,条件跳转一定不发生。这样的预测方法,其实也是一种静态预测技术。就好像猜硬币的时候一直猜正面,会有50%的正确率。如果分支预测是正确的,就意味着节省下了本来需要停顿下来等待的时间。

如果分支预测失败了,那就把后面已经取出指令已经执行的部分,给丢弃掉。这个丢弃的操作,在流水线里面,叫作Zap或者Flush。CPU不仅要执行后面的指令,对于这些已经在流水线里面执行到一半的指令,还需要做对应的清除操作。比如,清空已经使用的寄存器里面的数据等等,这些清除操作,也有一定的开销。所以,CPU需要提供对应的丢弃指令的功能,通过控制信号清除掉已经在流水线中执行的指令。只要对应的清除开销不要太大,就是划得来的,如下所示:

(3)动态分支预测。上面的静态预测策略看起来比较简单,预测的准确率也许有50%。但是如果运气不好,可能就会特别差。于是工程师们就开始思考更好的办法,比如根据之前条件跳转的比较结果来预测。这种策略叫一级分支预测(One Level Branch Prediction),或者叫1比特饱和计数(1-bit saturating counter)。这个方法其实就是用一个比特去记录当前分支的比较情况,直接用当前分支的比较情况,来预测下一次分支时候的比较情况

但是类似只用一天下雨,就预测第二天下雨这样的感觉还是有些草率,可以用更多的信息,而不只是一次的分支信息来进行预测。于是可以引入一个状态机(State Machine)来做这个事情。例如如果连续发生下雨的情况,就认为下面更有可能下雨。之后如果只有一天放晴了,仍然认为会下雨。在连续下雨之后,要连续两天放晴,才会认为之后会放晴。整个状态机的流转如下所示:

这个状态机里一共有4个状态,所以需要2个比特来记录对应的状态。这样的整个策略,就可以叫2比特饱和计数,或者叫双模态预测器(Bimodal Predictor)。

28. 循环嵌套的改变会影响CPU的性能,例如下面的代码例子:

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");
    }
}

一般我们会认为这两个三重for循环执行的总时间是一样的,但其实结果让人惊讶:

Time spent in first loop is 5ms
Time spent in second loop is 15ms

同样循环了十亿次,第一段程序只花了5毫秒,而第二段程序则花了15毫秒,足足多了3倍。这个差异就来自分支预测。循环其实也是利用cmp和jle这样先比较后跳转的指令来实现的,上面的代码每一次循环都有一个cmp和jle指令。每一个jle就意味着,要比较条件码寄存器的状态,决定是顺序执行代码,还是要跳转到另外一个地址。也就是说,在每一次循环发生的时候,都会有一次“分支”。

分支预测策略最简单的一个方式,自然是“假定跳转分支不发生”。对应上面的循环代码,就是循环始终会进行下去。在这样的情况下,上面的第一段循环,每隔10000次才会发生一次预测上的错误。而这样的错误,在第二层 j 的循环发生的次数是1000次,最外层的i的循环是100次,所以一共会发生100 × 1000 = 10万次预测错误。上面的第二段循环,则是每100次循环就会发生一次预测错误。这样的错误,在第二层j的循环发生的次数还是1000 次,最外层i的循环是10000次,所以一共会发生1000 × 10000 = 1000万次预测错误。

所以为什么同样空转次数相同的循环代码,第一段代码运行的时间要少得多了,因为第一段代码发生“分支预测”错误的情况比较少,更多的计算机指令,在流水线里顺序运行下去了,而不需要把运行到一半的指令丢弃掉,再去重新加载新的指令执行。虽然执行的指令数是一样的,但是分支预测失败得多的程序,性能就要差上几倍。因此以后写代码的时候养成良好习惯,按事件概率高低在分支中升序或降序安排,争取让状态机少判断

29. 在衡量程序执行时间的公式中:

程序的CPU执行时间 = 指令数 × CPI × Clock Cycle Time

公式里有一个叫CPI(每条指令的平均时钟周期数,Cycles Per Instruction)的指标。CPI的倒数又叫作IPC(Instruction Per Clock),也就是一个时钟周期里面能够执行的指令数,代表了CPU的吞吐率。那么这个指标,放在前面反复优化流水线架构 CPU里,如果只有1个指令译码器最佳情况下IPC也只能到1。因为无论做了哪些流水线层面的优化,即使做到了指令执行层面的乱序执行,CPU在1个指令译码器里仍然只能在一个时钟周期里面取一条指令,如下所示:

但是现代CPU实际的IPC都能做到2以上,这是怎么做到的呢?例如浮点数计算已经变成CPU里的一部分,但并不是所有计算功能都在一个ALU里面,真实情况是会有多个ALU。这也是为什么前面提到乱序执行的时候,会看到指令的执行阶段,是由很多个功能单元(FU)并行(Parallel)进行的。不过,在指令乱序执行的过程中,取指令(IF)和指令译码(ID)部分并不是并行进行的。

既然指令的执行层面可以并行进行,而取指令和指令译码如果也想要实现并行,其实只要把取指令和指令译码,也一样通过增加硬件的方式,并行进行就好了。可以一次性从内存里面取出多条指令,然后分发给多个并行的指令译码器,进行译码,然后对应交给不同的功能单元去处理。这样在一个时钟周期里,能够完成的指令就不只一条了,IPC也就能做到大于1。如下所示:

这种CPU设计叫作多发射(Mulitple Issue)或超标量(Superscalar)。多发射的意思就是同一个时间,可能会同时把多条指令发射(Issue)到不同的译码器或者后续处理的流水线中去。而在超标量的CPU里面,有很多条并行的流水线,而不是只有一条流水线。“超标量”这个词是说,本来在一个时钟周期里面,只能执行一个标量(Scalar)的运算。在多发射的情况下,就能够超越这个限制,同时进行多次计算,就像下面的图,一个时钟周期内几个指令批量同时开始执行:

可以看到每一个功能单元的流水线长度是不同的。事实上,不同功能单元的流水线长度本来就不一样。平时所说的14级流水线,指的通常是进行整数计算指令的流水线长度。如果是浮点数运算,实际的流水线长度则会更长一些。

对于乱序执行还是现在更进一步的超标量技术,在硬件层面实施起来都挺麻烦的。这是因为在乱序执行和超标量的体系里面,CPU要解决依赖冲突的问题,也就是前面的冒险问题。CPU需要在指令执行之前,去判断指令之间是否有依赖关系。如果有对应的依赖关系,指令就不能分发到执行阶段。因为这样,超标量CPU的多发射功能又被称为动态多发射处理器。这些对于依赖关系的检测,都会使得CPU电路变得更加复杂。在乱序执行和超标量的CPU架构里,指令的前后依赖关系是由CPU内部的硬件电路来检测的

30. 超标量(Superscalar)技术能够让取指令以及指令译码也并行进行,除此以外现代CPU还有两种提升性能的架构设计,分别是超线程(Hyper-Threading)技术,以及单指令多数据流(SIMD)技术。

当年Pentium 4失败的一个重要原因,就是它的CPU的流水线级数太深了,最多达到了31级。超长的流水线使得之前讲的很多解决“冒险”、提升并发的方案都用不上。因为这些解决“冒险”、提升并发的方案,本质上都是一种指令级并行(Instruction-level parallelism,简称 IPL)的技术方案。换句话说,就是CPU想要在同一个时间,去并行地执行两条指令。而这两条指令,原本在代码里是有先后顺序的。无论是流水线架构、分支预测以及乱序执行,还是超标量,都是想要通过同一时间执行两条指令,来提升CPU的吞吐率

然而在Pentium 4上,这些方法都可能因为流水线太深,而起不到效果。更深的流水线意味着同时在流水线里面的指令就多,相互的依赖关系就多。于是,很多时候不得不把流水线停顿下来,插入很多NOP操作,来解决这些依赖带来的“冒险”问题。

(1)于是后来Intel发明了超线程技术。既然CPU同时运行那些在代码层面有前后依赖关系的指令,会遇到各种冒险问题,不如去找一些和这些指令完全独立,没有依赖关系的指令来并行运行好了。这样的指令自然同时运行在另外一个程序里。然而无论是多个CPU核心运行不同的程序,还是在单个CPU核心里面切换运行不同线程的任务,在同一时间点上一个物理上的CPU核心只会运行一个线程的指令(类似时间片的概念),所以其实并没有真正地做到指令的并行运行

超线程的CPU,其实是把一个物理层面CPU核心,“伪装”成两个逻辑层面的 CPU 核心。这个CPU,会在硬件层面增加很多电路,使得可以在一个CPU核心内部,维护两个不同线程的指令的状态信息。比如,在一个物理CPU核心内部,会有双份的PC寄存器、指令寄存器乃至条件码寄存器。这样,这个CPU核心就可以维护两条并行的指令的状态。在外面看起来,似乎有两个逻辑层面的CPU在同时运行。所以,超线程技术一般也被叫作同时多线程(Simultaneous Multi-Threading,简称 SMT)技术,如下图所示:

不过,在CPU的其他功能组件上,无论是指令译码器还是ALU,一个CPU核心仍然只有一份。因为超线程并不是真的去同时运行两个指令,那就真的变成物理多核了。超线程的目的,是在一个线程A的指令,在流水线里停顿的时候,让另外一个线程去执行指令。因为这个时候,CPU的译码器和ALU就空出来了,那么另外一个线程B就可以拿来干自己需要的事情,这个线程B没有对于线程A里面指令的关联和依赖。这样,CPU通过很小的代价,就能实现“同时”运行多个线程的效果。通常只要在CPU核心的添加10%左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点。

由于并没有增加真的功能单元。所以超线程只在特定的应用场景下效果比较好,一般是在那些各个线程“等待”时间比较长的应用场景下,比如应对很多请求的数据库应用,此时各个指令都要等待访问内存数据,但是并不需要做太多计算。于是就可以利用好超线程,CPU计算并没有跑满,但是往往当前的指令要停顿在流水线上,等待内存里面的数据返回。这个时候,让CPU里的各个功能单元,去处理另外一个数据库连接的查询请求就是一个很好的应用案例,例如下面的CPU参数图:

在右下角里CPU的 Cores被标明了是4,而Threads则是8。这说明只有4个物理的CPU核心,也就是所谓的4核CPU。但是在逻辑层面,它“装作”有8个CPU核心,可以利用超线程技术,来同时运行8条指令。

(2)在上面CP信息的图里,中间有一组信息叫作Instructions,这些信息就是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

从两段程序的输出结果来看,两个功能相同的代码性能有着巨大的差异,也难怪Python讲解数据科学的教程里,往往在一开始就告诉不要使用循环,而要把所有的计算都向量化(Vectorize)。

有些人可能会猜测,是不是因为Python是一门解释性语言,所以这个性能差异会那么大。第一段程序循环的每一次操作都需要Python解释器来执行,而第二段的函数调用是一次调用编译好的原生代码,所以才会那么快。但其实,直接用C语言实现一下1000个元素的数组里面的每个数加1,会发现即使是C语言编译出来的代码,速度还是远远低于NumPy。原因就是,NumPy直接用到了SIMD指令,能够并行进行向量的操作

而前面使用循环来一步一步计算的算法被称为SISD,也就是单指令单数据(Single Instruction Single Data)的处理方式。如果是一个多核CPU,那么它同时处理多个指令的方式叫MIMD即多指令多数据(Multiple Instruction Multiple Data)。SIMD指令能快那么多是因为,SIMD在获取数据和执行指令的时候,都做到了并行

一方面,在从内存里面读取数据的时候,SIMD是一次性读取多个数据。以上面的程序为例,数组里每一项都是一个integer,也就是需要4 Bytes的内存空间。Intel在引入SSE指令集时在CPU里添上了8个128 Bits的寄存器,也就是16 Bytes,即一个寄存器一次性可以加载4个整数。比起循环分别读取4次对应的数据,时间就省下来了,如下所示:

另一方面,在数据读取到了之后,在指令的执行层面,SIMD 也是可以并行进行的。4个整数各自加1,互相之间完全没有依赖,也就没有冒险问题需要处理只要CPU里有足够多的功能单元,能够同时进行这些计算,这个加法就是4路同时并行的,自然也省下了时间。所以,对于那些在计算层面存在大量“数据并行”(Data Parallelism)的计算中,使用SIMD是一个很划算的办法。这个大量的“数据并行”,其实就是实践当中的向量运算或者矩阵运算。在实际程序开发过程中,过去通常是在进行图片、视频、音频的处理,最近几年则通常是在进行各种机器学习算法的计算。

31. 底层硬件和系统也会有点像程序语言一样有异常,比如加法器相加两个大数造成的溢出。它其实是一个硬件和软件组合到一起的处理过程。异常的前半段即异常的发生和捕捉,是在硬件层面完成的。但是异常的后半段即异常的处理,其实是由软件来完成的。计算机会为每一种可能会发生的异常,分配一个异常代码(Exception Number),也叫中断向量(Interrupt Vector)。异常发生的时候,通常是CPU检测到了一个特殊的信号,例如正在执行的指令发生了加法溢出,会有一个进位溢出的信号。这些信号在计算机组成原理里面,一般叫作发生了一个事件(Event)。CPU在检测到事件的时候,其实也就拿到了对应的异常代码。

这些异常代码里,I/O发出信号的异常代码,是由操作系统来分配的,也就是由软件来设定的。而像加法溢出这样的异常代码,则是由CPU预先分配好的,也就是由硬件来分配的。这又是另一个软件和硬件共同组合来处理异常的过程。 拿到异常代码之后,CPU就会触发异常处理的流程。计算机在内存里,会保留一个异常表(Exception Table),也叫作中断向量表(Interrupt Vector Table),这个异常表有点儿像GOT表,存放的是不同异常代码对应的异常处理程序(Exception Handler)所在的地址

CPU在拿到了异常码之后,会先把当前程序执行的现场,保存到程序栈里面,然后根据异常码查询,找到对应的异常处理程序,最后把后续指令执行的指挥权,交给这个异常处理程序,就像前后端开发中后端向前端的请求返回一个错误码一样,如下所示:

再比如Java里面,使用一个线程池去运行调度任务的时候,可以指定一个异常处理程序。对于各个线程在执行任务出现的异常情况,是通过异常处理程序进行处理,而不是在实际的任务代码里处理。这样,就把业务处理代码和异常处理代码的流程分开了。

32. 异常可以由硬件触发,也可以由软件触发。平时会碰到的常见异常有以下几种:

(1)中断(Interrupt)。顾名思义,自然就是程序在执行到一半的时候,被打断了。这个打断执行的信号,来自于CPU外部的I/O设备。在键盘上按下一个按键,就会对应触发一个相应的信号到达CPU里面。CPU里面某个开关的值发生了变化,也就触发了一个中断类型的异常。

(2)陷阱(Trap)。其实是程序员“故意“主动触发的异常。就好像在程序里打了一个断点,这个断点就是设下的一个"陷阱"。当程序的指令执行到这个位置的时候,就掉到了这个陷阱当中。然后,对应的异常处理程序就会来处理这个"陷阱"当中的猎物。

最常见的一类陷阱,发生在应用程序调用系统调用的时候,也就是从程序的用户态切换到内核态的时候。例如可以用Linux下的time指令,去查看一个程序运行实际花费的时间,里面有在用户态花费的时间(user time),也有在内核态发生的时间(system time)。应用程序通过系统调用去读取文件、创建进程,其实也是通过触发一次陷阱来进行的。这是因为,用户态的应用程序没有权限来做这些事情,需要把对应的流程转交给有权限的异常处理程序来进行

(3)故障(Fault)。它和陷阱的区别在于,陷阱是开发程序的时候刻意触发的异常,而故障通常不是。比如在进行加法计算发生了溢出,其实就是故障类型的异常。这个异常不是在开发的时候计划内的,也一样需要有对应的异常处理程序去处理。故障和陷阱、中断的一个重要区别是,故障在异常程序处理完成之后,仍然回来处理当前的指令,而不是去执行程序中的下一条指令,因为当前的指令因为故障的原因并没有成功执行完成

(4)中止(Abort)。与其说这是一种异常类型,不如说这是故障的一种特殊情况。当CPU遇到了故障,但是恢复不过来的时候,程序就不得不中止了。四种异常的概要如下所示:

在这四种异常里,中断异常的信号来自系统外部,而不是在程序自己执行的过程中,所以称之为“异步”类型的异常。而陷阱、故障以及中止类型的异常,是在程序自己执行的过程中发生的,所以称之为“同步“类型的异常。在处理异常的过程当中,无论是异步的中断,还是同步的陷阱和故障,都是采用同一套处理流程,也就是上面所谓的,“保存现场、异常代码查询、异常处理程序调用”。

33. 在实际的异常处理程序执行之前,CPU需要去做一次“保存现场”的操作。这个保存现场的操作,和函数调用原函数被压栈的过程非常相似。因为切换到异常处理程序的时候,其实就好像是去调用一个异常处理函数。指令的控制权被切换到了另外一个“函数”里面,所以自然要把当前正在执行的指令去压栈。这样,才能在异常处理程序执行完成之后,重新回到当前的指令继续往下执行。

不过,切换到异常处理程序,比起函数调用,还是要更复杂一些。原因有下面几点。

(1)因为异常情况往往发生在程序正常执行的预期之外,比如中断、故障发生的时候。所以,除了本来程序需要压栈的数据之外,还需要把CPU内当前运行程序用到的所有寄存器信息,都放到栈里面。最典型的就是条件码寄存器里面的内容。

(2)像陷阱这样的异常,涉及程序指令在用户态和内核态之间的切换。压栈的时候,对应的数据是压到内核栈里,而不是程序栈里

第三点,像故障这样的异常,在异常处理程序执行完成之后。从栈里返回出来,继续执行的不是顺序的下一条指令,而是故障发生的当前指令。因为当前指令因为故障没有正常执行成功,必须重新去执行一次。所以,对于异常这样的处理流程,不像是顺序执行的指令间的函数调用关系,而是更像两个不同的独立进程之间在CPU层面的切换,所以这个过程称之为上下文切换(Context Switch)。

34. 历史上曾经出现过CISC与RISC的CPU架构之争。对于“程序的CPU执行时间 = 指令数 × CPI × Clock Cycle Time”这个公式,CISC为了尽可能节省内存消耗,复杂指令都在硬件层面实现,存放大量复杂指令集,将复杂计算合成1个指令,其实就是通过优化指令数,来减少CPU的执行时间。而RISC的架构只存放最常用的简单指令,复杂指令在软件层面通过多个简单指令组合完成,CPU内空出来的复杂指令晶体管空间存放更多通用寄存器,也消耗更多内存,其实是在优化 CPI,因为RISC指令比较简单,需要的时钟周期就比较少。两者的比较如下所示:

然而最终还是Intel的CISC架构赢了。x86架构当时看起来相对于RISC的复杂度劣势,其实都来自于一个最重要的考量,那就是指令集的向前兼容性。因为x86在商业上太成功了,有大量的操作系统、编译器等系统软件只支持 x86的指令集,而在这些系统软件上,又有各种各样的应用软件。如果Intel要放弃x86的架构和指令集,开发一个 RISC架构的CPU,面临的第一个问题就是所有现有软件都是不兼容的。

当时Intel也在不断借鉴其他RISC处理器的设计思想。既然核心问题是要始终向前兼容x86的指令集,那么能不能不修改指令集,但是让CISC风格的指令集用RISC的形式在CPU里运行呢?于是Intel就开始在处理器里引入了微指令(Micro-Instructions/Micro-Ops)架构,这也让CISC和RISC的分界变得模糊了。

在微指令架构的CPU里面,编译器编译出来的机器码和汇编代码并没有发生什么变化。但在指令译码的阶段,指令译码器“翻译”出来的,不再是某一条 CPU 指令,译码器会把一条机器码“翻译”成好几条“微指令”。这里的一条条微指令,就不再是CISC风格的了,而是变成了固定长度的RISC风格的了。这些 RISC风格的微指令,会被放到一个微指令缓冲区里面,然后再从缓冲区里面,分发给到后面的超标量,并且是乱序执行的流水线架构里面。不过这个流水线架构里面接受的,就不是复杂的指令,而是精简的指令了。在这个架构里,指令译码器相当于变成了设计模式里的一个“适配器”(Adaptor)。这个适配器,填平了CISC和RISC之间的指令差异。

不过这样一个能够把CISC的指令译码成RISC指令的指令译码器,比原来的指令译码器要复杂。这也就意味着更复杂的电路和更长的译码时间:本来以为可以通过RISC提升的性能,结果又有一部分浪费在了指令译码上。之所以过去大家认为RISC优于CISC,那就是在实际的程序运行过程中,有80%运行的代码用着20%的常用指令。这意味着,CPU里执行的代码有很强的局部性。

而对于有着很强局部性的问题,常见的一个解决方案就是使用缓存。 所以,Intel 就在CPU里面加了一层L0 Cache。这个Cache保存的就是指令译码器把CISC的指令“翻译”成RISC的微指令的结果。于是,在大部分情况下,CPU 都可以从Cache里面拿到译码结果,而不需要让译码器去进行实际的译码操作。这样不仅优化了性能,因为译码器的晶体管开关动作变少了,还减少了功耗。因为“微指令”架构的存在,Intel处理器已经不是一个纯粹的CISC处理器了。它同样融合了大量RISC类型的处理器设计。不过,由于Intel本身在CPU层面做的大量优化,比如乱序执行、分支预测等相关工作,x86 CPU始终在功耗上还是要远远超过RISC架构的ARM,所以最终在智能手机崛起替代PC的时代,落在了ARM后面。

35. 早期GPU渲染图像的步骤是固定的,由以下5个步骤组成:

(1)顶点处理(Vertex Processing)。构成多边形建模的每一个多边形呢,都有多个顶点(Vertex)。这些顶点都有一个在三维空间里的坐标。但是屏幕是二维的,所以在确定当前视角的时候,需要把这些顶点在三维空间里面的位置,转化到屏幕这个二维空间里面。这个转换的操作,就被叫作顶点处理。这样的转化都是通过线性代数的计算来进行的。可以想见,建模越精细,需要转换的顶点数量就越多,计算量就越大。而且,这里面每一个顶点位置的转换,互相之间没有依赖,是可以并行独立计算的

(2)图元处理(Primitive Processing)。其实就是要把顶点处理完成之后的各个顶点连起来,变成多边形。其实转化后的顶点,仍然是在一个三维空间里,只是第三维的Z轴,是正对屏幕的“深度”。所以针对这些多边形,需要做一个操作,叫剔除和裁剪(Cull and Clip),也就是把不在屏幕里面,或者一部分不在屏幕里面的内容给去掉,减少接下来流程的工作量。

(3)栅格化(Rasterization)。屏幕分辨率是有限的。它一般是通过一个个“像素(Pixel)”来显示出内容的。所以,对于做完图元处理的多边形,要把它们转换成屏幕里面的一个个像素点。这个栅格化操作,有一个特点和上面的顶点处理是一样的,就是每一个图元都可以并行独立地栅格化

(4)片段处理(Fragment Processing)。在栅格化变成了像素点之后,图还是“黑白”的。还需要计算每一个像素的颜色、透明度等信息,给像素点上色。这步操作,同样也可以每个片段并行、独立进行

(5)像素操作(Pixel Operations)。最后一步呢就要把不同的多边形的像素点“混合(Blending)”到一起。可能前面的多边形可能是半透明的,那么前后的颜色就要混合在一起变成一个新的颜色;或者前面的多边形遮挡住了后面的多边形,那么我们只要显示前面多边形的颜色就好了。最终,输出到显示设备。

经过这完整的5个步骤之后,就完成了从三维空间里的数据渲染,变成屏幕上可以看到的3D动画了。这样5个步骤的渲染流程,一般也被称之为图形流水线(Graphic Pipeline)。早期显示器是640×480的分辨率,这意味着屏幕上有30万个像素需要渲染。为了让眼睛看到画面不晕眩,希望画面能有 60 帧。于是每秒就要重新渲染60次这个画面,也就是说每秒需要完成1800万次单个像素的渲染。从栅格化开始,每个像素有3个流水线步骤,即使每次步骤只有1个指令,那也每秒需要执行5400万条指令。

这些固定步骤的大量运算,如果全交给CPU完成会耗尽CPU的主频性能。既然图形渲染的流程是固定的,那直接用硬件来处理这部分过程,不用CPU来计算就好了,因此显卡诞生。显然,它不需要考虑CPU那样流水线停顿、乱序执行等各类导致CPU计算变得复杂的问题,也不需要有什么可编程能力,只要让硬件按照写好的固定逻辑步骤进行大量运算就好了,早期显卡还没有分担顶点处理的功能,这一步过去依然由CPU完成,如下所示:

36. 渐渐的,程序员希望GPU也能有一定的可编程能力。这个编程能力不是像CPU那样,有非常通用的指令,可以进行任何希望的操作,而是在整个的渲染管线(Graphics Pipeline)的一些特别步骤,能够自己去定义处理数据的算法或者操作。最终微软的可编程管线(Programable Function Pipeline)出现了,如下所示:

一开始的可编程管线,仅限于顶点处理(Vertex Processing)和片段处理(Fragment Processing)部分。比起原来只能通过显卡和Direct3D这样的图形接口提供固定配置,程序员们终于也可以开始在图形效果上开始大显身手了。这些可以编程的接口称之为Shader,就是着色器,叫这个名字是因为一开始这些“可编程”的接口,只能修改顶点处理和片段处理部分的程序逻辑。用这些接口来做的,也主要是光照、亮度、颜色等等的处理。

这个时候的GPU有两类Shader,也就是Vertex Shader和Fragment Shader。在进行顶点处理的时候,操作的是多边形的顶点;在片段操作的时候,操作的是屏幕上的像素点。对于顶点的操作,通常比片段要复杂一些。所以一开始,这两类Shader都是独立的硬件电路,也各自有独立的编程接口。因为这么做,硬件设计起来更加简单,一块GPU上也能容纳下更多的Shader。

不过大家很快发现,虽然在顶点处理和片段处理上的具体逻辑不太一样,但是里面用到的指令集可以用同一套。而且,虽然把Vertex Shader和Fragment Shader分开可以减少硬件设计的复杂度,但是也带来了浪费,有一半Shader始终没有被使用。在整个渲染管线里,Vertext Shader运行的时候,Fragment Shader停在那里。Fragment Shader在运行的时候,Vertext Shader也停在那里。本来 GPU 就不便宜,结果设计的电路有一半时间是闲着的。于是,统一着色器架构(Unified Shader Architecture)就应运而生了,如下所示:

既然两种shader用的指令集是一样的,那不如就在GPU里放很多个一样的Shader硬件电路,然后通过统一调度把顶点处理、图元处理、片段处理这些任务,都交给这些Shader去处理,让整个 GPU 尽可能地忙起来。这样就是现代GPU的设计。正是因为Shader变成一个“通用”的模块,才有了把GPU拿来做各种通用计算的用法,也就是GPGPU(General-Purpose Computing on Graphics Processing Units,通用图形处理器)。而正是因为GPU可以拿来做各种通用的计算,深度学习才开始火起来。

37. 现代CPU里的晶体管变得越来越多,越来越复杂,其实已经不是用来实现“计算”这个核心功能,而是拿来实现处理乱序执行、进行分支预测,以及高速缓存等。而GPU里,这些电路就显得有点多余了,GPU的整个处理过程是一个流式处理(Stream Processing)的过程。因为没有那么多分支条件,或者复杂的依赖关系,可以把GPU里这些对应的电路都可以去掉,只留下取指令、指令译码、ALU以及执行这些计算需要的寄存器和缓存就好了。一般来说会把这些电路抽象成三个部分,就是下图中的取指令和指令译码、ALU和执行上下文:

这样一来GPU电路就比CPU简单很多了。于是就可以在一个GPU里面,塞很多个并行的GPU电路来实现计算,就好像多核CPU一样。和CPU不同的是,GPU不需要单独去实现多线程的计算。因为GPU的运算是天然并行的,如下所示:

CPU里有SIMD这种处理技术,即在做向量计算的时候,要执行的指令是一样的,只是同一个指令的数据有所不同而已。在GPU的渲染管线里,类似这种技术就大有用处了。无论是顶点进行线性变换,还是屏幕上临近像素点的光照和上色,都是在用相同的指令流程进行计算。所以,GPU就借鉴了CPU的SIMD,用了一种叫作SIMT(Single Instruction,Multiple Threads)的技术。SIMT 比SIMD更灵活,在SIMD里面CPU一次性取出固定长度的多个数据,放到寄存器里面用一个指令去执行。而SIMT可以把多条数据交给不同的线程去处理

各个线程里面执行的指令流程是一样的,但是可能根据数据的不同,走到不同的条件分支。这样,相同的代码和相同的流程,可能执行不同的具体的指令。这个线程走到的是if的条件分支,另外一个线程走到的就是else的条件分支了。于是GPU设计就可以进一步进化,也就是在取指令和指令译码的阶段,取出的指令可以给到后面多个不同的ALU并行进行运算。这样一个GPU的核里,就可以放下更多的ALU,同时进行更多的并行运算了。

38. 虽然GPU里面主要以数值计算为主,不过既然已经是一个“通用计算”的架构了,GPU里面也避免不了会有 if…else 这样的条件分支。但是,GPU里没有CPU这样的分支预测的电路。这些电路在上面“芯片瘦身”的时候就已经被砍掉了。所以,GPU里的指令可能会遇到和CPU类似的“流水线停顿”问题。类似CPU里的超线程技术,在GPU上也可以做类似的事情,也就是遇到停顿的时候,调度一些别的计算任务给当前的ALU。和超线程一样,既然要调度一个不同的任务过来,就需要针对这个任务,提供更多的执行上下文。所以,一个Core里面的执行上下文的数量,需要比ALU,如下所示:

39. 在通过芯片瘦身、SIMT以及更多的执行上下文,就有了一个更擅长并行进行暴力运算的GPU。这样的芯片也正适合今天的深度学习的使用场景。 一方面,GPU是一个可以进行“通用计算”的框架,可以通过编程在GPU上实现不同的算法。另一方面,现在的深度学习计算,都是超大的向量和矩阵,海量的训练样本的计算。整个计算过程中,没有复杂的逻辑和分支,非常适合GPU这样并行、计算能力强的架构

例如当今英伟达RTX2080 一共有46个SM(Streaming Multiprocessor,流式处理器),相当于GPU里面的GPU Core,所以可以认为这是一个46核的GPU,有46个取指令指令译码的渲染管线。每个SM里面有64个Cuda Core。可以认为这里的Cuda Core就是上面说的ALU的数量或者Pixel Shader的数量,46x64一共就有2944个 Shader。然后,还有184个TMU,TMU就是Texture Mapping Unit,也就是用来做纹理映射的计算单元,它也可以认为是另一种类型的Shader。

RTX2080的主频是1515MHz,如果自动超频(Boost)的话,可以到1700MHz。而NVidia显卡根据硬件架构的设计,每个时钟周期可以执行两条指令。所以,能做的浮点数运算的能力就是:(2944 + 184)× 1700 MHz × 2 = 10.06 TFLOPS。而目前的Intel i9 9900K的性能是不到1TFLOPS,2080显卡和9900K的价格却是差不多的。所以在实际进行深度学习的过程中,用GPU所花费的时间往往能减少一到两个数量级。而大型的深度学习模型计算,往往又是多卡并行,要花上几天乃至几个月。这个时候,用 CPU 显然就不合适了。

40. 就像写程序一样,芯片内部连接晶体管组成各种功能非常困难,设计简单一点的专用于特定功能的芯片都要几个月。设计一个CPU往往要以“年”来计。在这个过程中如果每次验证一个方案,都要单独设计生产一块芯片,代价太高了。如果不用单独制造一块专门的芯片来验证硬件设计,成本就会小很多。能不能设计一个硬件,通过不同的程序代码,来操作这个硬件的电路连线,通过“编程”让这个硬件变成想设计的某种电路连线的芯片呢?

这个就是FPGA,也就是现场可编程门阵列(Field-Programmable Gate Array)。Gate代表芯片里面的门电路。Array说的是在一块FPGA上,密密麻麻列了大量Gate这样的门电路。Field是说一块FPGA这样的板子,可以在“现场”多次进行编程。它不像PAL(Programmable Array Logic,可编程阵列逻辑)只能“编程”一次,把预先写好的程序一次性烧录到硬件里面,之后就不能再修改了。

CPU其实就是通过晶体管,来实现各种组合逻辑或者时序逻辑。对于FPGA,“编程”连接这些线路的步骤如下:

(1) 用存储换功能实现组合逻辑。实现CPU的功能的时候需要完成各种各样的电路逻辑,在FPGA里这些基本的电路逻辑,不是采用布线连接的方式进行的,而是预先根据在软件里面设计的逻辑电路,算出对应的真值表,然后直接存到一个叫作 LUT(Look-Up Table,查找表)的电路里面。这个LUT其实就是一块存储空间,里面存储了“特定的输入信号下,对应输出 0 还是 1”,如下所示:

例如要实现一个函数,需要返回斐波那契数列的第N项,由于斐波那契数列的通项公式是f(N) = f(N-1) + f(N-2) ,所以正常方法是写一个程序从第1项开始算。对于FPGA的LUT来说,就是预先用程序算好斐波那契数量前N项,然后把它预先放到一个数组里面。当要计算第 N 项的时候,并不是去计算得到结果,而是直接查找这个数组里面的第N

(2)对于需要实现的时序逻辑电路,可以在FPGA里面直接放上D触发器作为寄存器。FPGA会把很多LUT 的电路和寄存器组合在一起,变成一个逻辑簇(Logic Cluster)。在FPGA里这样组合了多个LUT和寄存器的设备,也被叫做CLB(Configurable Logic Block,可配置逻辑块)。通过配置CLB实现的功能有点像全加器,它已经在最基础的门电路上做了组合,能够提供更复杂一点的功能。更复杂的芯片功能,不用再从门电路搭起,可以通过CLB组合搭建出来,如下所示:

(3)FPGA是通过可编程逻辑布线,来连接各个不同的CLB,最终实现想要实现的芯片功能。这个可编程逻辑布线就像铁路网。整个铁路系统已经铺好了,但是整个铁路网里面,设计了很多个道岔。可以通过控制道岔来确定不同的列车线路。在可编程逻辑布线里面,“编程”在做的就是拨动像道岔一样的各个电路开关,最终实现不同CLB之间的连接,完成想要的芯片功能。

于是,通过LUT和寄存器,能够组合出很多CLB,而通过连接不同的CLB,最终有了想要的芯片功能。而且这个组合过程是可以“编程”控制的。这个编程出来的软件,还可以后续改写,重新写入到硬件里。让同一个硬件实现不同的芯片功能

41. 除了CPU、GPU和FPGA,其实还需要用到很多其他芯片。比如手机里有专门用在摄像头里的芯片;录音笔里会有专门处理音频的芯片。尽管一个CPU能够处理好手机拍照和录音的功能,但是直接在手机或者录音笔里塞上一个Intel CPU,显然比较浪费。于是就考虑为这些有专门用途的场景,单独设计一个芯片,称之为ASIC(Application-Specific Integrated Circuit),也就是专用集成电路。因为ASIC是针对专门用途设计的,所以它的电路更精简,单片的制造成本也比CPU更低。而且,因为电路精简,所以通常能耗要比用来做通用计算的CPU更低。早期的图形加速卡,其实就可以看作是一种ASIC。

对FPGA进行“编程”,其实就是把FPGA的电路变成了一个ASIC。这样的芯片往往在成本和功耗上优于需要做通用计算的CPU和GPU。但是放弃ASIC全都用FPGA也不行,因为它在硬件上有点“浪费”。每一个LUT电路,其实都是一个小小的“浪费”。一个LUT电路设计出来之后,既可以实现与门,又可以实现或门,自然用到的晶体管数量,比单纯连死的与门或者或门的要多得多。同时因为用的晶体管多,它的能耗也比单纯连死的电路要大,单片FPGA生产制造的成本也比ASIC要高不少

当然,FPGA相比ASIC的优点在于它没有硬件研发成本。ASIC的电路设计需要仿真、验证,还需要经过流片(Tape out),变成一个印刷的电路版,最终变成芯片。这整个从研发到上市的过程,最低花费也要几万美元,高的话会在几千万乃至数亿美元。更何况,整个设计还有失败的可能。所以,如果设计的专用芯片只是少量制造几千片,那买几千片现成的FPGA,可能远比花上几百万美元来设计、制造ASIC要经济得多

单个ASIC的生产制造成本比FPGA,ASIC的能耗也比能实现同样功能的FPGA要低。但是ASIC有一笔很高的 NRE(Non-Recuring Engineering Cost,一次性工程费用)成本。这个成本就是ASIC实际“研发”的成本。只有需要大量生产ASIC芯片的时候,才能摊薄这份研发成本

42. GPU天生适合进行海量、并行的矩阵数值计算,于是被大量用在深度学习的模型训练上。在深度学习中计算量最大的并不是进行深度学习的训练,而是深度学习的推断部分。所谓推断部分,是指在完成深度学习训练之后,把训练完成的模型存储下来。这个存储下来的模型,是许许多多个向量组成的参数。然后,根据这些参数去计算输入的数据,最终得到一个计算结果。这个推断过程,可能是在互联网广告领域,去推测某一个用户是否会点击特定的广告;也可能是在经过高铁站的时候,扫一下身份证进行一次人脸识别,判断一下是不是本人。

而算法模型的训练和应用推断的区别,有以下三点:

(1)深度学习的推断工作更简单,对灵活性的要求也就更低。模型推断的过程,只需要去计算一些矩阵的乘法、加法,调用一些Sigmoid或者RELU这样的激活函数。这样的过程可能需要反复进行很多层,但是也只是这些计算过程的简单组合。

(2)深度学习的推断性能,首先要保障响应时间的指标。计算机关注的性能指标,有响应时间(Response Time)和吞吐率(Throughput)。在模型训练的时候,只需要考虑吞吐率问题就行了。因为一个模型训练少则好几分钟,多的话要几个月。而推断过程,像互联网广告的点击预测,往往希望能在几十毫秒乃至几毫秒之内就完成,而人脸识别也不希望会超过几秒钟。很显然,模型训练和推断对于性能的要求是截然不同的。

(3)深度学习的推断工作,希望在功耗上尽可能少一些。深度学习的训练,对功耗没有那么敏感,只是希望训练速度能够尽可能快,多费点电就多费点儿了。而训练完成后深度学习的推断,要7×24h地跑在数据中心里面,对应的芯片要大规模地部署。一块芯片减少5%的功耗,就能节省大量的电费。而深度学习的训练工作,大部分情况下只是少部分算法工程师用少量的机器进行。很多时候只是做小规模的实验,尽快得到结果节约人力成本。

43. 专用于深度学习的TPU结构如下所示:

TPUv1的设计理念有以下几点:

(1)向前兼容。TPU并没有设计成一个独立的“CPU“,而是设计成一块像显卡一样,插在主板PCI-E接口上的板卡。更进一步地,TPU甚至没有像现代GPU一样,设计成自己有对应的取指令的电路,而是通过CPU向TPU发送需要执行的指令。这两个设计,使得TPU的硬件设计变得简单了,只需要专心完成一个专用的“计算芯片”就好了。这样一个TPU是一个像FPU(浮点数处理器)的协处理器(Coprocessor),而不是像CPU和GPU这样可以独立工作的Processor Unit。

(2)完全是按照深度学习一个层(Layer)的计算流程来设计的。可以看到在芯片模块图里面,有单独的矩阵乘法单元(Matrix Multiply Unit)、累加器(Accumulators)模块、激活函数(Activation)模块和归一化/池化(Normalization/Pool)模块。而且,这些模块是顺序串联在一起的。这是因为一个深度学习的推断过程,是由很多层的计算组成的。而每一个层(Layer)的计算过程,就是先进行矩阵乘法,再进行累加,接着调用激活函数,最后进行归一化和池化。这里的硬件设计就是把整个流程变成一套固定的硬件电路。这也是一个ASIC的典型设计思路,其实就是把确定的程序指令流程,变成固定的硬件电路。

其中控制电路(Control)只占了2%。这是因为TPU的计算过程基本上是一个固定的流程。不像CPU那样有各种复杂的控制功能比如冒险、分支预测等等。可以看到,超过一半的TPU面积,都被用来作为Local Unified Buffer(本地统一缓冲区)(29%)和矩阵乘法单元(Matrix Mutliply Unit)了。相比于矩阵乘法单元,累加器、实现激活函数和后续的归一/池化功能的激活管线(Activation Pipeline)也用得不多。这是因为,在深度学习推断的过程中,矩阵乘法的计算量是最大的,计算也更复杂,所以比简单的累加器和激活函数要占用更多的晶体管。

而统一缓冲区(Unified Buffer),则由SRAM这样高速的存储设备组成。SRAM一般被直接拿来作为CPU寄存器或者高速缓存。SRAM比起内存使用的DRAM速度要快上很多,但是因为电路密度小,所以占用的空间要大很多。统一缓冲区之所以使用SRAM,是因为在整个的推断过程中,它会高频反复地被矩阵乘法单元读写,来完成计算。TPU各硬件电路的交互如下所示:

可以看到,整个TPU里面每一个组件的设计,完全是为了深度学习的推断过程设计出来的。这也是设计开发ASIC的核心原因:用特制的硬件,最大化特定任务的运行效率

44. 上述TPU中的矩阵乘法单元,没有用32 Bits来存放一个浮点数,而是只用了一个8 Bits来存放浮点数。这是因为在机器学习应用中,会对数据做归一化(Normalization)和正则化(Regularization)的处理。这两个操作会使在深度学习里面操作的数据都不会变得太大。通常都能控制在-3到3这样的范围内。因为这个数值上的特征,需要的浮点数的精度也不需要太高了。32位浮点数的精度差不多可以到1~1600万。如果用8位或者16位表示浮点数,也能把精度放到2^6或者2^12,在深度学习里常常够用了。特别是在模型推断的时候,要求的计算精度往往可以比模型训练低。所以,8 Bits的矩阵乘法器,就可以放下更多的计算量,使得TPU的推断速度更快

一方面,在性能上,TPU比现在的CPU、GPU在深度学习的推断任务上要快15~30倍。而在能耗比上,更是好出30~80倍。另一方面,Google已经用TPU替换了自家数据中心里 95% 的推断任务。

45. 现代公有云的虚拟机能够快速发展,起源于电商巨头亚马逊大量富余的计算能力。和国内“双十一”一样,美国“黑色星期五(Black Friday)”这样的大型电商促销活动期间,会有大量的用户进入亚马逊买东西。这时亚马逊需要的服务器计算资源可能是平时的数十倍。于是,亚马逊会按照“黑色星期五”的用户访问量来准备服务器资源。

然而一年中大部分时间这些服务器资源是大量空闲的,今天估计已经有超过千万台的服务器。平时有这么多闲着的服务器太浪费了,所以亚马逊就想把这些服务器给租出去出租整个物理服务器不太容易自动化,大部分中小客户不需要那么大的计算能力也付不起。为了节约数据中心的空间,亚马逊实际用的物理服务器大部分多半是强劲的高端8核乃至12核服务器。想要租用这些服务器的中小公司,起步往往只需要1个CPU核心乃至更少资源的服务器。一次性要他们去租一整台服务器,就好像刚毕业想要租个单间,结果非要整租个别墅给他的概念。

这个“整租”的问题,还发生在“时间”层面。物理服务器里面装好的系统和应用,不租了而要再给其他人使用,就必须清空里面已经装好的程序和数据,得做一次“重装”。如果自己只是暂时不用这个服务器了,过一段时间又要租这个服务器,数据中心服务商就不得不先重装整个系统,然后租给别人。等别人不用了,再重装系统租给自己,特别麻烦

其实,对于想要租用服务器的用户来说,最好的体验就像是住酒店,住一天就付一天的钱,如果是全家出门一次就多定几间房间。这样的需求使虚拟机技术应运而生,使得可以在一台物理服务器上,同时运行多个虚拟服务器,并且可以动态去分配每个虚拟服务器占用的资源。对于不运行的虚拟服务器,也可以把这个虚拟服务器“关闭”这个“关闭”了的虚拟服务器,就和一个被关掉的物理服务器一样,不会再占用实际的服务器资源。但是,当重新打开这个虚拟服务器的时候,里面的数据和应用都在,不需要再重新安装一次

46. 虚拟机(Virtual Machine)技术,其实就是指在现有硬件的操作系统上,能够模拟一个计算机系统的技术。要模拟一个计算机系统,最简单的办法,就是兼容这个计算机系统的指令集。可以开发一个应用程序跑在操作系统上,这个应用程序可以识别想要模拟的计算机系统的程序格式和指令,然后一条条去解释执行。在这个过程中原先的操作系统叫作宿主机(Host),把能够有能力去模拟指令执行的软件,叫作模拟器(Emulator),而实际运行在模拟器上被“虚拟”出来的系统叫客户机(Guest VM)。

例如一个Android开发人员在开发机上跑的Android模拟器,和很多能在Windows下运行的游戏机模拟器,其实就是这种方式。这种解释执行方式的最大的优势就是,模拟的系统可以跨硬件。比如,Android手机用的CPU是ARM的,而开发机用的是Intel X86的,两边的CPU指令集都不一样,但是一样可以正常运行。不过这个方式也有两个明显的缺陷:

(1)做不到精确的“模拟”。很多老旧硬件的程序运行,要依赖特定的电路乃至电路特有的时钟频率,想要通过软件达到100%模拟是很难做到的。

(2)这种解释执行的方式,原有性能资源损耗严重且较慢。因为并不是直接把指令交给CPU去执行的,而是要经过各种解释和翻译工作。所以,虽然模拟器这样的形式有它的实际用途。甚至为了解决性能问题,也有类似于Java当中的JIT这样的“编译优化”的办法,把本来解释执行的指令,编译成Host可以直接运行的指令。但是,这个性能还是不能让人满意。比如空出来的计算能力像是个大平层,结果经过模拟器之后能够租出去的计算能力就变成了一个格子间,那就太浪费了。

47. 为了解决上述这种解释型虚拟机的性能问题,有了下面的改进思路:希望虚拟化技术能够克服上面的模拟器方式的两个缺陷。同时可以放弃掉模拟器方式能做到的跨硬件平台的这个能力。因为中小客户想要租的也是一个x86的服务器。而且他们希望这个租用的服务器用起来,和直接买一台或者租一台物理服务器在流畅度上没有区别。作为出租方也希望服务器不要因为用了虚拟化技术,而在中间损耗掉太多的性能

所以,首先需要一个“全虚拟化”的技术,也就是说可以在现有物理服务器的硬件和操作系统上,去跑一个完整的、不需要做任何修改的客户机操作系统(Guest OS)。为了在一个操作系统上,去跑多个完整的操作系统,就有了软件开发中很常用的一个解决方案,就是加入一个中间层叫作虚拟机监视器即VMM(Virtual Machine Manager)或者 Hypervisor。

如果说宿主机的OS是房东,虚拟机监视器就像二房东。运行的虚拟机都不是直接和房东打交道,而是要和二房东打交道。跑在上面的虚拟机会把整个硬件特征都映射到虚拟机环境里,包括整个完整的CPU指令集、I/O操作、中断等等。虚拟机监视器实际的指令落到硬件上去执行有2种方法:

(1)Type-1虚拟机。客户机的指令交给虚拟机监视器之后,不再需要通过宿主机的操作系统才能调用硬件,而是可以直接由虚拟机监视器去调用硬件。此时并不需要在Intel x86上面去跑一个ARM的程序,而是直接在x86上虚拟一个x86硬件的计算机和操作系统。因此指令不需要做什么翻译工作,可以直接往下传递执行就好了,所以指令的执行效率也会很高。此时虚拟机监视器其实并不是一个操作系统之上的应用层程序,而是嵌入在操作系统内核里的一部分。例如KVM、XEN 还是微软的Hyper-V都是系统级的程序。这种高效率虚拟机更多在数据中心里使用。

(2)Type-2虚拟机。虚拟机监视器好像一个运行在操作系统上的软件,客户机的操作系统把最终到硬件的所有指令,都发送给虚拟机监视器。而虚拟机监视器又会把这些指令再交给宿主机的操作系统去执行。这和上面的模拟器方式看起来没有太大区别,只是把在模拟器里的指令翻译工作,挪到了虚拟机监视器里。这种方式更多是用在日常的个人电脑里,例如Virtual Box和Vmware Workstation。

48. 虽然Type-1型虚拟机看起来已经没有太大硬件损耗。但是在实际的物理机上同时运行了多个虚拟机,而这每一个虚拟机都运行了一个属于自己的单独操作系统。多运行一个操作系统,意味着要多消耗一些资源在CPU、内存乃至磁盘空间上。那能否不要多运行的这个操作系统呢?其实是可以的,因为用户想要的未必是一个完整的、独立的、全虚拟化的虚拟机,很多时候想要租用的不是“独立服务器”,而是独立的计算资源。在服务器领域开发的程序都是跑在Linux上的,其实并不需要一个独立的操作系统,只要一个能够进行资源和环境隔离的“独立空间”就好了

因此,Docker技术就这样诞生了。在服务器端开发中,虽然应用环境需要各种各样不同的依赖,例如可能是不同的PHP或者Python的版本,也可能是操作系统里面不同的系统库,但通常来说都是跑在Linux内核上的。通过Docker不再需要在操作系统上再跑一个操作系统,而是直接运行在操作系统内核上,通过容器编排工具比如Kubernetes或者Docker Swarm,能够进行各个应用之间的环境和资源隔离就好了

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!