This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
<audio id="audio" title="17 | 建立数据通路(上):指令+运算=CPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ad/f7/ad5ffc922bb6c35898e9c99c014842f7.mp3"></audio>
前面几讲里我从两个不同的部分为你讲解了CPU的功能。
在“**指令**”部分,我为你讲解了计算机的“指令”是怎么运行的,也就是我们撰写的代码,是怎么变成一条条的机器能够理解的指令的,以及是按照什么样的顺序运行的。
在“**计算**”部分,我为你讲解了计算机的“计算”部分是怎么执行的,数据的二进制表示是怎么样的,我们执行的加法和乘法又是通过什么样的电路来实现的。
然而光知道这两部分还不能算是真正揭开了CPU的秘密只有把“指令”和“计算”这两部分功能连通起来我们才能构成一个真正完整的CPU。这一讲我们就在前面知识的基础上来看一个完整的CPU是怎么运转起来的。
## 指令周期Instruction Cycle
前面讲计算机机器码的时候我向你介绍过PC寄存器、指令寄存器还介绍过MIPS体系结构的计算机所用到的R、I、J类指令。如果我们仔细看一看可以发现计算机每执行一条指令的过程可以分解成这样几个步骤。
1.**Fetch****取得指令**也就是从PC寄存器里找到对应的指令地址根据指令地址从内存里把具体的指令加载到指令寄存器中然后把PC寄存器自增好在未来执行下一条指令。
2.**Decode****指令译码**也就是根据指令寄存器里面的指令解析成要进行什么样的操作是R、I、J中的哪一种指令具体要操作哪些寄存器、数据或者内存地址。
3.**Execute****执行指令**也就是实际运行对应的R、I、J这些特定的指令进行算术逻辑操作、数据传输或者直接的地址跳转。
4.重复进行13的步骤。
这样的步骤,其实就是一个永不停歇的“**Fetch - Decode - Execute**”的循环,我们把这个循环称之为**指令周期**Instruction Cycle
<img src="https://static001.geekbang.org/resource/image/18/a7/1840bead02cfbe5d8f70e2f0a7b962a7.jpg" alt="">
在这个循环过程中,不同部分其实是由计算机中的不同组件完成的。不知道你还记不记得,我们在专栏一开始讲的计算机组成的五大组件?
在取指令的阶段,我们的指令是放在**存储器**里的实际上通过PC寄存器和指令寄存器取出指令的过程是由**控制器**Control Unit操作的。指令的解码过程也是由**控制器**进行的。一旦到了执行指令阶段无论是进行算术操作、逻辑操作的R型指令还是进行数据传输、条件分支的I型指令都是由**算术逻辑单元**ALU操作的也就是由**运算器**处理的。不过,如果是一个简单的无条件地址跳转,那么我们可以直接在**控制器**里面完成,不需要用到运算器。
<img src="https://static001.geekbang.org/resource/image/bd/67/bde3548a4789ba49cab74c8c1ab02a67.jpeg" alt="">
除了Instruction Cycle这个指令周期在CPU里面我们还会提到另外两个常见的Cycle。一个叫**Machine Cycle****机器周期**或者**CPU周期**。CPU内部的操作速度很快但是访问内存的速度却要慢很多。每一条指令都需要从内存里面加载而来所以我们一般把从内存里面读取一条指令的最短时间称为CPU周期。
还有一个是我们之前提过的**Clock Cycle**,也就是**时钟周期**以及我们机器的主频。一个CPU周期通常会由几个时钟周期累积起来。一个CPU周期的时间就是这几个Clock Cycle的总和。
对于一个指令周期来说我们取出一条指令然后执行它至少需要两个CPU周期。取出指令至少需要一个CPU周期执行至少也需要一个CPU周期复杂的指令则需要更多的CPU周期。
<img src="https://static001.geekbang.org/resource/image/1a/48/1a7d2d6cf7cb78a8f48775268f452e48.jpeg" alt="">
所以我们说一个指令周期包含多个CPU周期而一个CPU周期包含多个时钟周期。
## 建立数据通路
在专栏一开始不少同学留言问到ALU就是运算器吗在讨论计算机五大组件的运算器的时候我们提到过好几个不同的相关名词比如ALU、运算器、处理器单元、数据通路它们之间到底是什么关系呢
名字是什么其实并不重要,一般来说,我们可以认为,数据通路就是我们的处理器单元。它通常由两类原件组成。
第一类叫**操作元件**也叫组合逻辑元件Combinational Element其实就是我们的ALU。在前面讲ALU的过程中可以看到它们的功能就是在特定的输入下根据下面的组合电路的逻辑生成特定的输出。
第二类叫**存储元件**也有叫状态元件State Element的。比如我们在计算过程中需要用到的寄存器无论是通用寄存器还是状态寄存器其实都是存储元件。
我们通过数据总线的方式,把它们连接起来,就可以完成数据的存储、处理和传输了,这就是所谓的**建立数据通路**了。
下面我们来说**控制器**。它的逻辑就没那么复杂了。我们可以把它看成只是机械地重复“Fetch - Decode - Execute“循环中的前两个步骤然后把最后一个步骤通过控制器产生的控制信号交给ALU去处理。
听起来是不是很简单?实际上,控制器的电路特别复杂。下面我给你详细解析一下。
一方面所有CPU支持的指令都会在控制器里面被解析成不同的输出信号。我们之前说过现在的Intel CPU支持2000个以上的指令。这意味着控制器输出的控制信号至少有2000种不同的组合。
运算器里的ALU和各种组合逻辑电路可以认为是一个固定功能的电路。控制器“翻译”出来的就是不同的控制信号。这些控制信号告诉ALU去做不同的计算。可以说正是控制器的存在让我们可以“编程”来实现功能能让我们的“存储程序型计算机”名副其实。
<img src="https://static001.geekbang.org/resource/image/46/6f/46087a894b4ac182fab83ac3786cad6f.jpeg" alt="">
## CPU所需要的硬件电路
那么要想搭建出来整个CPU我们需要在数字电路层面实现这样一些功能。
首先自然是我们之前已经讲解过的ALU了它实际就是一个没有状态的根据输入计算输出结果的第一个电路。
第二我们需要有一个能够进行状态读写的电路元件也就是我们的寄存器。我们需要有一个电路能够存储到上一次的计算结果。这个计算结果并不一定要立刻拿到电路的下游去使用但是可以在需要的时候拿出来用。常见的能够进行状态读写的电路就有锁存器Latch以及我们后面要讲的D触发器Data/Delay Flip-flop的电路。
第三我们需要有一个“自动”的电路按照固定的周期不停地实现PC寄存器自增自动地去执行“Fetch - Decode - Execute“的步骤。我们的程序执行并不是靠人去拨动开关来执行指令的。我们希望有一个“自动”的电路不停地去一条条执行指令。
我们看似写了各种复杂的高级程序进行各种函数调用、条件跳转。其实只是修改PC寄存器里面的地址。PC寄存器里面的地址一修改计算机就可以加载一条指令新指令往下运行。实际上PC寄存器还有一个名字就叫作程序计数器。顾名思义就是随着时间变化不断去数数。数的数字变大了就去执行一条新指令。所以我们需要的就是一个自动数数的电路。
第四我们需要有一个“译码”的电路。无论是对于指令进行decode还是对于拿到的内存地址去获取对应的数据或者指令我们都需要通过一个电路找到对应的数据。这个对应的自然就是“译码器”的电路了。
好了现在我们把这四类电路通过各种方式组合在一起就能最终组成功能强大的CPU了。但是要实现这四种电路中的中间两种我们还需要时钟电路的配合。下一节我们一起来看一看这些基础的电路功能是怎么实现的以及怎么把这些电路组合起来变成一个CPU。
## 总结延伸
好了到这里我们已经把CPU运转需要的数据通路和控制器介绍完了也找出了需要完成这些功能需要的4种基本电路。它们分别是ALU这样的组合逻辑电路、用来存储数据的锁存器和D触发器电路、用来实现PC寄存器的计数器电路以及用来解码和寻址的译码器电路。
虽然CPU已经是由几十亿个晶体管组成的及其复杂的电路但是它仍然是由这样一个个基本功能的电路组成的。只要搞清楚这些电路的运作原理你自然也就弄明白了CPU的工作原理。
## 推荐阅读
如果想要了解数据通路,可以参看《计算机组成与设计 硬件软件接口》的第5版的4.1到4.4节。专栏里的内容是从更高一层的抽象逻辑来解释这些问题,而教科书里包含了更多电路的技术细节。这两者结合起来学习,能够帮助你更深入地去理解数据通路。
## 课后思考
这一讲我们说CPU好像一个永不停歇的机器一直在不停地读取下一条指令去运行。那为什么CPU还会有满载运行和Idle闲置的状态呢请你自己搜索研究一下这是为什么并在留言区写下你的思考和答案。
欢迎你留言和我分享,你也可以把今天的文章分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,108 @@
<audio id="audio" title="18 | 建立数据通路(中):指令+运算=CPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/48/15cd5a49c4bc40891fcfc233e8035548.mp3"></audio>
上一讲我们看到要能够实现一个完整的CPU功能除了加法器这样的电路之外我们还需要实现其他功能的电路。其中有一些电路和我们实现过的加法器一样只需要给定输入就能得到固定的输出。这样的电路我们称之为**组合逻辑电路**Combinational Logic Circuit
但是光有组合逻辑电路是不够的。你可以想一下如果只有组合逻辑电路我们的CPU会是什么样的电路输入是确定的对应的输出自然也就确定了。那么我们要进行不同的计算就要去手动拨动各种开关来改变电路的开闭状态。这样的计算机不像我们现在每天用的功能强大的电子计算机反倒更像古老的计算尺或者机械计算机干不了太复杂的工作只能协助我们完成一些计算工作。
这样,我们就需要引入第二类的电路,也就是**时序逻辑电路**Sequential Logic Circuit。时序逻辑电路可以帮我们解决这样几个问题。
第一个就是**自动运行**的问题。时序电路接通之后可以不停地开启和关闭开关进入一个自动运行的状态。这个使得我们上一讲说的控制器不停地让PC寄存器自增读取下一条指令成为可能。
第二个是**存储**的问题。通过时序电路实现的触发器,能把计算结果存储在特定的电路里面,而不是像组合逻辑电路那样,一旦输入有任何改变,对应的输出也会改变。
第三个本质上解决了各个功能按照**时序协调**的问题。无论是程序实现的软件指令,还是到硬件层面,各种指令的操作都有先后的顺序要求。时序电路使得不同的事件按照时间顺序发生。
## 时钟信号的硬件实现
想要实现时序逻辑电路,第一步我们需要的就是一个**时钟**。我在[第3讲](https://time.geekbang.org/column/article/92215)说过CPU的主频是由一个晶体振荡器来实现的而这个晶体振荡器生成的电路信号就是我们的时钟信号。
实现这样一个电路,和我们之前讲的,通过电的磁效应产生开关信号的方法是一样的。只不过,这里的磁性开关,打开的不再是后续的线路,而是当前的线路。
在下面这张图里你可以看到我们在原先一般只放一个开关的信号输入端放上了两个开关。一个开关A一开始是断开的由我们手工控制另外一个开关B一开始是合上的磁性线圈对准一开始就合上的开关B。
于是一旦我们合上开关A磁性线圈就会通电产生磁性开关B就会从合上变成断开。一旦这个开关断开了电路就中断了磁性线圈就失去了磁性。于是开关B又会弹回到合上的状态。这样一来电路接通线圈又有了磁性。我们的电路就会来回不断地在开启、关闭这两个状态中切换。
<img src="https://static001.geekbang.org/resource/image/57/c0/57684c12e7bf8ef429220405b0e3bdc0.jpeg" alt="">
这个不断切换的过程对于下游电路来说就是不断地产生新的0和1这样的信号。如果你在下游的电路上接上一个灯泡就会发现这个灯泡在亮和暗之间不停切换。这个按照固定的周期不断在0和1之间切换的信号就是我们的**时钟信号**Clock Signal
一般这样产生的时钟信号就像你在各种教科书图例中看到的一样是一个振荡产生的0、1信号。
<img src="https://static001.geekbang.org/resource/image/6d/93/6dd534a167513c865dfe1921ebb6ae93.jpeg" alt="">
这种电路,其实就相当于把电路的输出信号作为输入信号,再回到当前电路。这样的电路构造方式呢,我们叫作**反馈电路**Feedback Circuit
接下来,我们还会看到更多的反馈电路。上面这个反馈电路一般可以用下面这个示意图来表示,其实就是一个输出结果接回输入的**反相器**Inverter也就是我们之前讲过的**非门**。
<img src="https://static001.geekbang.org/resource/image/d2/ca/d205493f6ff1aeba7a849575285bbeca.jpg" alt="">
## 通过D触发器实现存储功能
有了时钟信号我们的系统里就有了一个像“自动门”一样的开关。利用这个开关和相同的反馈电路我们就可以构造出一个有“记忆”功能的电路。这个有记忆功能的电路可以实现在CPU中用来存储计算结果的寄存器也可以用来实现计算机五大组成部分之一的存储器。
<img src="https://static001.geekbang.org/resource/image/dc/de/dc6dcce612b2fd51939d7ec44b3fe1de.jpeg" alt="">
我们先来看下面这个RS触发器电路。这个电路由两个或非门电路组成。我在图里面把它标成了A和B。
<img src="https://static001.geekbang.org/resource/image/7d/a9/7dd38282b8862cb6541ee82e76e1e0a9.jpg" alt="">
<li>
在这个电路一开始输入开关都是关闭的所以或非门NORA的输入是0和0。对应到我列的这个真值表输出就是1。而或非门B的输入是0和A的输出1对应输出就是0。B的输出0反馈到A和之前的输入没有变化A的输出仍然是1。而整个电路的**输出Q**也就是0。
</li>
<li>
当我们把A前面的开关R合上的时候A的输入变成了1和0输出就变成了0对应B的输入变成0和0输出就变成了1。B的输出1反馈给到了AA的输入变成了1和1输出仍然是0。所以把A的开关合上之后电路仍然是稳定的不会像晶振那样振荡但是整个电路的**输出Q**变成了1。
</li>
<li>
这个时候如果我们再把A前面的开关R打开A的输入变成和1和0输出还是0对应的B的输入没有变化输出也还是1。B的输出1反馈给到了AA的输入变成了1和0输出仍然是0。这个时候电路仍然稳定。**开关R和S的状态和上面的第一步是一样的但是最终的输出Q仍然是1**和第1步里Q状态是相反的。我们的输入和刚才第二步的开关状态不一样但是输出结果仍然保留在了第2步时的输出没有发生变化。
</li>
<li>
这个时候只有我们再去关闭下面的开关S才可以看到这个时候B有一个输入必然是1所以B的输出必然是0也就是电路的最终**输出Q**必然是0。
</li>
这样一个电路我们称之为触发器Flip-Flop。接通开关R输出变为1即使断开开关输出还是1不变。接通开关S输出变为0即使断开开关输出也还是0。也就是**当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个也就是我们说的记忆功能**。
这里的这个电路是最简单的RS触发器也就是所谓的复位置位触发器Reset-Set Flip Flop) 。对应的输出结果的真值表你可以看下面这个表格。可以看到当两个开关都是0的时候对应的输出不是1或者0而是和Q的上一个状态一致。
<img src="https://static001.geekbang.org/resource/image/ac/cb/acc43093e8f0da21b660b4cb5d3d05cb.jpg" alt="">
再往这个电路里加两个与门和一个小小的时钟信号我们就可以实现一个利用时钟信号来操作一个电路了。这个电路可以帮我们实现什么时候可以往Q里写入数据。
我们看看下面这个电路这个在我们的上面的R-S触发器基础之上在R和S开关之后加入了两个与门同时给这两个与门加入了一个**时钟信号CLK**作为电路输入。
这样当时钟信号CLK在低电平的时候与门的输入里有一个0两个实际的R和S后的与门的输出必然是0。也就是说无论我们怎么按R和S的开关根据R-S触发器的真值表对应的Q的输出都不会发生变化。
只有当时钟信号CLK在高电平的时候与门的一个输入是1输出结果完全取决于R和S的开关。我们可以在这个时候通过开关R和S来决定对应Q的输出。
<img src="https://static001.geekbang.org/resource/image/9e/d8/9e9bc411aa8c7bf2f080f306a0fb8bd8.jpeg" alt="">
如果这个时候我们让R和S的开关也用一个反相器连起来也就是通过同一个开关控制R和S。只要CLK信号是1R和S就可以设置输出Q。而当CLK信号是0的时候无论R和S怎么设置输出信号Q是不变的。这样这个电路就成了我们最常用的D型触发器。用来控制R和S这两个开关的信号呢我们视作一个输入的数据信号D也就是Data这就是D型触发器的由来。
<img src="https://static001.geekbang.org/resource/image/d7/bb/d749acce21756d89c35ee19545cfebbb.jpeg" alt="">
一个D型触发器只能控制1个比特的读写但是如果我们同时拿出多个D型触发器并列在一起并且把用同一个CLK信号控制作为所有D型触发器的开关这就变成了一个N位的D型触发器也就可以同时控制N位的读写。
CPU里面的寄存器可以直接通过D型触发器来构造。我们可以在D型触发器的基础上加上更多的开关来实现清0或者全部置为1这样的快捷操作。
## 总结延伸
好了,到了这里,我们可以顺一顺思路了。通过引入了时序电路,我们终于可以把数据“存储”下来了。我们通过反馈电路,创建了时钟信号,然后再利用这个时钟信号和门电路组合,实现了“状态记忆”的功能。
电路的输出信号不单单取决于当前的输入信号还要取决于输出信号之前的状态。最常见的这个电路就是我们的D触发器它也是我们实际在CPU内实现存储功能的寄存器的实现方式。
这也是现代计算机体系结构中的“冯·诺伊曼”机的一个关键,就是程序需要可以“存储”,而不是靠固定的线路连接或者手工拨动开关,来实现计算机的可存储和可编程的功能。
有了时钟信号和触发器之后,我们还差一个“自动”需求没有实现。我们的计算机还不能做到自动地不停地从内存里面读取指令去执行。这一部分,我们留在下一讲。下一讲里,我们看看怎么让程序自动运转起来。
## 推荐阅读
想要深入了解计算机里面的各种功能组件是怎么通过电路来实现的推荐你去阅读《编码隐匿在计算机软硬件背后的语言》这本书的第14章和16章。
如果对于数字电路和数字逻辑特别感兴趣,想要彻底弄清楚数字电路、时序逻辑电路,也可以看一看计算机学科的一本专业的教科书《数字逻辑应用与设计》。
## 课后思考
现在我们的CPU主频非常高了通常在几GHz了但是实际上我们的晶振并不能提供这么高的频率而是通过“外频+倍频“的方式来实现高频率的时钟信号。请你研究一下,倍频和分频的信号是通过什么样的电路实现的?
欢迎留言和我分享你的疑惑和见解,也欢迎你把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,96 @@
<audio id="audio" title="19 | 建立数据通路(下):指令+运算=CPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/6a/0b9668f7ad4510e1b3acea9a35214d6a.mp3"></audio>
上一讲,我们讲解了时钟信号是怎么实现的,以及怎么利用这个时钟信号,来控制数据的读写,可以使得我们能把需要的数据“存储”下来。那么,这一讲,我们要让计算机“自动”跑起来。
通过一个时钟信号我们可以实现计数器这个会成为我们的PC寄存器。然后我们还需要一个能够帮我们在内存里面寻找指定数据地址的译码器以及解析读取到的机器指令的译码器。这样我们就能把所有学习到的硬件组件串联起来变成一个CPU实现我们在计算机指令的执行部分的运行步骤。
## PC寄存器所需要的计数器
我们常说的PC寄存器还有个名字叫程序计数器。下面我们就来看看它为什么叫作程序计数器。
有了时钟信号我们可以提供定时的输入有了D型触发器我们可以在时钟信号控制的时间点写入数据。我们把这两个功能组合起来就可以实现一个自动的计数器了。
加法器的两个输入一个始终设置成1另外一个来自于一个D型触发器A。我们把加法器的输出结果写到这个D型触发器A里面。于是D型触发器里面的数据就会在固定的时钟信号为1的时候更新一次。
<img src="https://static001.geekbang.org/resource/image/1e/4c/1ed21092022057ed192a7d9aff76144c.jpg" alt="">
这样我们就有了一个每过一个时钟周期就能固定自增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的输入信号一致。
<img src="https://static001.geekbang.org/resource/image/38/a0/383bfbb085c1eeb9b9473ae6f18e97a0.jpeg" alt="">
一个反向器只能有0和1这样两个状态所以我们只能从两个地址中选择一个。如果输入的信号有三个不同的开关我们就能从$2^3$也就是8个地址中选择一个了。这样的电路我们就叫**3-8译码器**。现代的计算机如果CPU是64位的就意味着我们的寻址空间也是$2^{64}$那么我们就需要一个有64个开关的译码器。
<img src="https://static001.geekbang.org/resource/image/40/01/4002b5f8f60a913e655d5268348ee201.jpeg" alt="">
所以说其实译码器的本质就是从输入的多个位的信号中根据一定的开关和电路组合选择出自己想要的信号。除了能够进行“寻址”之外我们还可以把对应的需要运行的指令码同样通过译码器找出我们期望执行的指令也就是在之前我们讲到过的opcode以及后面对应的操作数或者寄存器地址。只是这样的“译码器”比起2-1选择器和3-8译码器要复杂的多。
## 建立数据通路构造一个最简单的CPU
D触发器、自动计数以及译码器再加上一个我们之前说过的ALU我们就凑齐了一个拼装一个CPU必须要的零件了。下面我们就来看一看怎么把这些零件组合起来才能实现指令执行和算术逻辑计算的CPU。
<img src="https://static001.geekbang.org/resource/image/68/71/6863e10fc635791878d1ecd57618b871.jpeg" alt="">
1. 首先我们有一个自动计数器。这个自动计数器会随着时钟主频不断地自增来作为我们的PC寄存器。
1. 在这个自动计数器的后面我们连上一个译码器。译码器还要同时连着我们通过大量的D触发器组成的内存。
1. 自动计数器会随着时钟主频不断自增从译码器当中找到对应的计数器所表示的内存地址然后读取出里面的CPU指令。
1. 读取出来的CPU指令会通过我们的CPU时钟的控制写入到一个由D触发器组成的寄存器也就是指令寄存器当中。
1. 在指令寄存器后面我们可以再跟一个译码器。这个译码器不再是用来寻址的了而是把我们拿到的指令解析成opcode和对应的操作数。
1. 当我们拿到对应的opcode和操作数对应的输出线路就要连接ALU开始进行各种算术和逻辑运算。对应的计算结果则会再写回到D触发器组成的寄存器或者内存当中。
这样的一个完整的通路也就完成了我们的CPU的一条指令的执行过程。在这个过程中你会发现这样几个有意思的问题。
第一个,是我们之前在[第6讲](https://time.geekbang.org/column/article/94075)讲过的程序跳转所使用的条件码寄存器。那时讲计算机的指令执行的时候我们说高级语言中的if…else其实是变成了一条cmp指令和一条jmp指令。cmp指令是在进行对应的比较比较的结果会更新到条件码寄存器当中。jmp指令则是根据条件码寄存器当中的标志位来决定是否进行跳转以及跳转到什么地址。
不知道你当时看到这个知识点的时候有没有一些疑惑为什么我们的if…else会变成这样两条指令而不是设计成一个复杂的电路变成一条指令到这里我们就可以解释了。这样分成两个指令实现完全匹配好了我们在电路层面“译码-执行-更新寄存器“这样的步骤。
cmp指令的执行结果放到了条件码寄存器里面我们的条件跳转指令也是在ALU层面执行的而不是在控制器里面执行的。这样的实现方式在电路层面非常直观我们不需要一个非常复杂的电路就能实现if…else的功能。
第二个,是关于我们在[](https://time.geekbang.org/column/article/98872)[17讲](https://time.geekbang.org/column/article/98872)里讲到的指令周期、CPU周期和时钟周期的差异。在上面的抽象的逻辑模型中你很容易发现我们执行一条指令其实可以不放在一个时钟周期里面可以直接拆分到多个时钟周期。
我们可以在一个时钟周期里面去自增PC寄存器的值也就是指令对应的内存地址。然后我们要根据这个地址从D触发器里面读取指令这个还是可以在刚才那个时钟周期内。但是对应的指令写入到指令寄存器我们可以放在一个新的时钟周期里面。指令译码给到ALU之后的计算结果要写回到寄存器又可以放到另一个新的时钟周期。所以执行一条计算机指令其实可以拆分到很多个时钟周期而不是必须使用单指令周期处理器的设计。
因为从内存里面读取指令时间很长所以如果使用单指令周期处理器就意味着我们的指令都要去等待一些慢速的操作。这些不同指令执行速度的差异也正是计算机指令有指令周期、CPU周期和时钟周期之分的原因。因此现代我们优化CPU的性能时用的CPU都不是单指令周期处理器而是通过流水线、分支预测等技术来实现在一个周期里同时执行多个指令。
## 总结延伸
好了今天我们讲完了怎么通过连接不同功能的电路实现出一个完整的CPU。
我们可以通过自动计数器的电路来实现一个PC寄存器不断生成下一条要执行的计算机指令的内存地址。然后通过译码器从内存里面读出对应的指令写入到D触发器实现的指令寄存器中。再通过另外一个译码器把它解析成我们需要执行的指令和操作数的地址。这些电路组成了我们计算机五大组成部分里面的控制器。
我们把opcode和对应的操作数发送给ALU进行计算得到计算结果再写回到寄存器以及内存里面来这个就是我们计算机五大组成部分里面的运算器。
我们的时钟信号,则提供了协调这样一条条指令的执行时间和先后顺序的机制。同样的,这也带来了一个挑战,那就是单指令周期处理器去执行一条指令的时间太长了。而这个挑战,也是我们接下来的几讲里要解答的问题。
## 推荐阅读
《编码隐匿在计算机软硬件背后的语言》的第17章用更多细节的流程来讲解了CPU的数据通路。《计算机组成与设计 硬件/软件接口》的4.1到4.4小节从另外一个层面和角度讲解了CPU的数据通路的建立推荐你阅读一下。
## 课后思考
CPU在执行无条件跳转的时候不需要通过运算器以及ALU可以直接在控制器里面完成你能说说这是为什么吗
欢迎在留言区写下你的思考和疑惑,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,83 @@
<audio id="audio" title="20 | 面向流水线的指令设计一心多用的现代CPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/90/7e68e27c9ef990bfa090d9d7993c0290.mp3"></audio>
前面我们用了三讲用一个个的电路组合制作出了一个完整功能的CPU。这里面一下子给你引入了三个“周期”的概念分别是指令周期、机器周期或者CPU周期以及时钟周期。
你可能会有点摸不着头脑了为什么小小一个CPU有那么多的周期Cycle我们在专栏一开始不是把CPU的性能定义得非常清楚了吗我们说程序的性能是由三个因素相乘来衡量的我们还专门说过“指令数×CPI×时钟周期”这个公式。这里面和周期相关的只有一个时钟周期也就是我们CPU的主频倒数。当时讲的时候我们说一个CPU的时钟周期可以认为是可以完成一条最简单的计算机指令的时间。
那么为什么我们在构造CPU的时候一下子出来了那么多个周期呢这一讲我就来为你说道说道带你更深入地看看现代CPU是怎么一回事儿。
## 愿得一心人,白首不相离:单指令周期处理器
学过前面三讲你现在应该知道一条CPU指令的执行是由“取得指令Fetch-指令译码Decode-执行指令Execute ”这样三个步骤组成的。这个执行过程,至少需要花费一个时钟周期。因为在取指令的时候,我们需要通过时钟周期的信号,来决定计数器的自增。
那么很自然地我们希望能确保让这样一整条指令的执行在一个时钟周期内完成。这样我们一个时钟周期可以执行一条指令CPI也就是1看起来就比执行一条指令需要多个时钟周期性能要好。采用这种设计思路的处理器就叫作单指令周期处理器Single Cycle Processor也就是在一个时钟周期内处理器正好能处理一条指令。
不过,我们的时钟周期是固定的,但是指令的电路复杂程度是不同的,所以实际一条指令执行的时间是不同的。在[第13讲](https://time.geekbang.org/column/article/95883)和[第14讲](https://time.geekbang.org/column/article/97477)讲加法器和乘法器电路的时候,我给你看过,随着门电路层数的增加,由于门延迟的存在,位数多、计算复杂的指令需要的执行时间会更长。
不同指令的执行时间不同但是我们需要让所有指令都在一个时钟周期内完成那就只好把时钟周期和执行时间最长的那个指令设成一样。这就好比学校体育课1000米考试我们要给这场考试预留的时间肯定得和跑得最慢的那个同学一样。因为就算其他同学先跑完也要等最慢的同学跑完间我们才能进行下一项活动。
<img src="https://static001.geekbang.org/resource/image/6c/ee/6c85e2dd9b9988d8a458fb1200d96eee.jpeg" alt="">
所以在单指令周期处理器里面无论是执行一条用不到ALU的无条件跳转指令还是一条计算起来电路特别复杂的浮点数乘法运算我们都等要等满一个时钟周期。在这个情况下虽然CPI能够保持在1但是我们的时钟频率却没法太高。因为太高的话有些复杂指令没有办法在一个时钟周期内运行完成。那么在下一个时钟周期到来开始执行下一条指令的时候前一条指令的执行结果可能还没有写入到寄存器里面。那下一条指令读取的数据就是不准确的就会出现错误。
<img src="https://static001.geekbang.org/resource/image/30/9b/3097988ae8dfc82e33ab80234bd5a29b.jpeg" alt="">
到这里你会发现,这和我们之前[第3讲](https://time.geekbang.org/column/article/92215)和[第4讲](https://time.geekbang.org/column/article/93246)讲时钟频率时候的说法不太一样。当时我们说一个CPU时钟周期可以认为是完成一条简单指令的时间。为什么到了这里单指令周期处理器反而变成了执行一条最复杂的指令的时间呢
这是因为无论是PC上使用的Intel CPU还是手机上使用的ARM CPU都不是单指令周期处理器而是采用了一种叫作**指令流水线**Instruction Pipeline的技术。
## 无可奈何花落去,似曾相识燕归来:现代处理器的流水线设计
其实CPU执行一条指令的过程和我们开发软件功能的过程很像。
如果我们想开发一个手机App上的功能并不是找来一个工程师告诉他“你把这个功能开发出来”然后他就吭哧吭哧把功能开发出来。真实的情况是无论只有一个工程师还是有一个开发团队我们都需要先对开发功能的过程进行切分把这个过程变成“撰写需求文档、开发后台API、开发客户端App、测试、发布上线”这样多个独立的过程。每一个后面的步骤都要依赖前面的步骤。
我们的指令执行过程也是一样的它会拆分成“取指令、译码、执行”这样三大步骤。更细分一点的话执行的过程其实还包含从寄存器或者内存中读取数据通过ALU进行运算把结果写回到寄存器或者内存中。
如果我们有一个开发团队我们不会让后端工程师开发完API之后就歇着等待前台App的开发、测试乃至发布而是会在客户端App开发的同时着手下一个需求的后端API开发。那么同样的思路我们可以一样应用在CPU执行指令的过程中。
通过过去三讲你应该已经知道了CPU的指令执行过程其实也是由各个电路模块组成的。我们在取指令的时候需要一个译码器把数据从内存里面取出来写入到寄存器中在指令译码的时候我们需要另外一个译码器把指令解析成对应的控制信号、内存地址和数据到了指令执行的时候我们需要的则是一个完成计算工作的ALU。这些都是一个一个独立的组合逻辑电路我们可以把它们看作一个团队里面的产品经理、后端工程师和客户端工程师共同协作来完成任务。
<img src="https://static001.geekbang.org/resource/image/1e/ad/1e880fa8b1eab511583267e68f0541ad.jpeg" alt="">
这样一来,我们就不用把时钟周期设置成整条指令执行的时间,而是拆分成完成这样的一个一个小步骤需要的时间。同时,每一个阶段的电路在完成对应的任务之后,也不需要等待整个指令执行完成,而是可以直接执行下一条指令的对应阶段。
这就好像我们的后端程序员不需要等待功能上线就会从产品经理手中拿到下一个需求开始开发API。这样的协作模式就是我们所说的**指令流水线**。这里面每一个独立的步骤,我们就称之为**流水线阶段**或者流水线级Pipeline Stage
如果我们把一个指令拆分成“取指令-指令译码-执行指令”这样三个部分那这就是一个三级的流水线。如果我们进一步把“执行指令”拆分成“ALU计算指令执行-内存访问-数据写回”,那么它就会变成一个五级的流水线。
五级的流水线就表示我们在同一个时钟周期里面同时运行五条指令的不同阶段。这个时候虽然执行一条指令的时钟周期变成了5但是我们可以把CPU的主频提得更高了。**我们不需要确保最复杂的那条指令在时钟周期里面执行完成,而只要保障一个最复杂的流水线级的操作,在一个时钟周期内完成就好了。**
如果某一个操作步骤的时间太长我们就可以考虑把这个步骤拆分成更多的步骤让所有步骤需要执行的时间尽量都差不多长。这样也就可以解决我们在单指令周期处理器中遇到的性能瓶颈来自于最复杂的指令的问题。像我们现代的ARM或者Intel的CPU流水线级数都已经到了14级。
虽然我们不能通过流水线来减少单条指令执行的“延时”这个性能指标但是通过同时在执行多条指令的不同阶段我们提升了CPU的“吞吐率”。在外部看来我们的CPU好像是“一心多用”在同一时间同时执行5条不同指令的不同阶段。在CPU内部其实它就像生产线一样不同分工的组件不断处理上游传递下来的内容而不需要等待单件商品生产完成之后再启动下一件商品的生产过程。
## 超长流水线的性能瓶颈
既然流水线可以增加我们的吞吐率你可能要问了为什么我们不把流水线级数做得更深呢为什么不做成20级乃至40级呢这个其实有很多原因我在之后几讲里面会详细讲解。这里我先讲一个最基本的原因就是增加流水线深度其实是有性能成本的。
我们用来同步时钟周期的不再是指令级别的而是流水线阶段级别的。每一级流水线对应的输出都要放到流水线寄存器Pipeline Register里面然后在下一个时钟周期交给下一个流水线级去处理。所以每增加一级的流水线就要多一级写入到流水线寄存器的操作。虽然流水线寄存器非常快比如只有20皮秒ps$10^{-12}$秒)。
<img src="https://static001.geekbang.org/resource/image/d9/26/d9e141af3f2c5eedd5aed438388cfe26.jpeg" alt="">
但是如果我们不断加深流水线这些操作占整个指令的执行时间的比例就会不断增加。最后我们的性能瓶颈就会出现在这些overhead上。如果我们指令的执行有3纳秒也就是3000皮秒。我们需要20级的流水线那流水线寄存器的写入就需要花费400皮秒占了超过10%。如果我们需要50级流水线就要多花费1纳秒在流水线寄存器上占到25%。这也就意味着单纯地增加流水线级数不仅不能提升性能反而会有更多的overhead的开销。所以设计合理的流水线级数也是现代CPU中非常重要的一点。
## 总结延伸
讲到这里相信你已经能够理解为什么我们的CPU需要流水线设计了也能把每一个流水线阶段在干什么和上一讲的整个CPU的数据通路的连接过程对上了。
可以看到为了能够不浪费CPU的性能我们通过把指令的执行过程切分成一个一个流水线级来提升CPU的吞吐率。而我们本身的CPU的设计又是由一个个独立的组合逻辑电路串接起来形成的天然能够适合这样采用流水线“专业分工”的工作方式。
因为每一级的overhead一味地增加流水线深度并不能无限地提高性能。同样地因为指令的执行不再是顺序地一条条执行而是在上一条执行到一半的时候下一条就已经启动了所以也给我们的程序带来了很多挑战。这些挑战和对应的解决方案就要请你坚持关注后面的几讲我们一起来揭开答案了。
## 推荐阅读
想要了解CPU的流水线设计可以参看《深入理解计算机系统》的4.4章节,以及《计算机组成与设计 硬件/软件接口》的4.5章节。
## 课后思考
我们在前面讲过一个CPU的时钟周期可以认为是完成一条简单指令的时间。在这一讲之后你觉得这句话正确吗为什么在了解了CPU的流水线设计之后你是怎么理解这句话的呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,113 @@
<audio id="audio" title="21 | 面向流水线的指令设计奔腾4是怎么失败的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/12/c2145ebdd2b36b43a0a5915662144012.mp3"></audio>
上一讲我给你初步介绍了CPU的流水线技术。乍看起来流水线技术是一个提升性能的灵丹妙药。它通过把一条指令的操作切分成更细的多个步骤可以避免CPU“浪费”。每一个细分的流水线步骤都很简单所以我们的单个时钟周期的时间就可以设得更短。这也变相地让CPU的主频提升得很快。
这一系列的优点也引出了现代桌面CPU的最后一场大战也就是Intel的Pentium 4和AMD的Athlon之间的竞争。在技术上这场大战Intel可以说输得非常彻底Pentium 4系列以及后续Pentium D系列所使用的NetBurst架构被完全抛弃退出了历史舞台。但是在商业层面Intel却通过远超过AMD的财力、原本就更大的市场份额、无所不用的竞争手段以及最终壮士断腕般放弃整个NetBurst架构最终依靠新的酷睿品牌战胜了AMD。
在此之后整个CPU领域竞争的焦点不再是Intel和AMD之间的桌面CPU之战。在ARM架构通过智能手机的快速普及后来居上超越Intel之后移动时代的CPU之战变成了高通、华为麒麟和三星之间的“三国演义”。
## “主频战争”带来的超长流水线
我们在[第3讲](https://time.geekbang.org/column/article/92215)里讲过我们其实并不能简单地通过CPU的主频就来衡量CPU乃至计算机整机的性能。因为不同的CPU实际的体系架构和实现都不一样。同样的CPU主频实际的性能可能差别很大。所以在工业界更好的衡量方式通常是用SPEC这样的跑分程序从多个不同的实际应用场景来衡量计算机的性能。
但是跑分对于消费者来说还是太复杂了。在Pentium 4的CPU面世之前绝大部分消费者并不是根据跑分结果来判断CPU的性能的。大家判断一个CPU的性能通常只看CPU的主频。而CPU的厂商们也通过不停地提升主频把主频当成技术竞赛的核心指标。
Intel一向在“主频战争”中保持领先但是到了世纪之交的1999年到2000年情况发生了变化。
1999年AMD发布了基于K7架构的Athlon处理器其综合性能超越了当年的Pentium III。2000年在大部分CPU还在500850MHz的频率下运行的时候AMD推出了第一代Athlon 1000处理器成为第一款1GHz主频的消费级CPU。在2000年前后AMD的CPU不但性能和主频比Intel的要强价格还往往只有Intel的2/3。
在巨大的外部压力之下Intel在2001年推出了新一代的NetBurst架构CPU也就是Pentium 4和Pentium D。Pentium 4的CPU有个最大的特点就是高主频。2000年的Athlon 1000的主频在当时是最高的1GHz然而Pentium 4设计的目标最高主频是10GHz。
<img src="https://static001.geekbang.org/resource/image/9b/f8/9bcdc5d5c7c1342085d70fe50d5190f8.jpeg" alt="">
为了达到这个10GHzIntel的工程师做出了一个重大的错误决策就是在NetBurst架构上使用超长的流水线。这个超长流水线有多长呢我们拿在Pentium 4之前和之后的CPU的数字做个比较你就知道了。
Pentium 4之前的Pentium III CPU流水线的深度是11级也就是一条指令最多会拆分成11个更小的步骤来操作而CPU同时也最多会执行11条指令的不同Stage。随着技术发展到今天你日常用的手机ARM的CPU或者Intel i7服务器的CPU流水线的深度是14级。
可以看到差不多20年过去了通过技术进步现代CPU还是增加了一些流水线深度的。那2000年发布的Pentium 4的流水线深度是多少呢答案是20级比Pentium III差不多多了一倍而到了代号为Prescott的90纳米工艺处理器Pentium 4Intel更是把流水线深度增加到了31级。
要知道增加流水线深度在同主频下其实是降低了CPU的性能。因为一个Pipeline Stage就需要一个时钟周期。那么我们把任务拆分成31个阶段就需要31个时钟周期才能完成一个任务而把任务拆分成11个阶段就只需要11个时钟周期就能完成任务。在这种情况下31个Stage的3GHz主频的CPU其实和11个Stage的1GHz主频的CPU性能是差不多的。事实上因为每个Stage都需要有对应的Pipeline寄存器的开销这个时候更深的流水线性能可能还会更差一些。
我在上一讲也说过,流水线技术并不能缩短单条指令的**响应时间**这个性能指标,但是可以增加在运行很多条指令时候的**吞吐率**。因为不同的指令,实际执行需要的时间是不同的。我们可以看这样一个例子。我们顺序执行这样三条指令。
1. 一条整数的加法需要200ps。
1. 一条整数的乘法需要300ps。
1. 一条浮点数的乘法需要600ps。
如果我们是在单指令周期的CPU上运行最复杂的指令是一条浮点数乘法那就需要600ps。那这三条指令都需要600ps。三条指令的执行时间就需要1800ps。
如果我们采用的是6级流水线CPU每一个Pipeline的Stage都只需要100ps。那么在这三个指令的执行过程中在指令1的第一个100ps的Stage结束之后第二条指令就开始执行了。在第二条指令的第一个100ps的Stage结束之后第三条指令就开始执行了。这种情况下这三条指令顺序执行所需要的总时间就是800ps。那么在1800ps内使用流水线的CPU比单指令周期的CPU就可以多执行一倍以上的指令数。
虽然每一条指令从开始到结束拿到结果的时间并没有变化,也就是响应时间没有变化。但是同样时间内,完成的指令数增多了,也就是吞吐率上升了。
<img src="https://static001.geekbang.org/resource/image/b0/6a/b055676975e68a7d4014e46969058f6a.jpeg" alt="">
## 新的挑战:冒险和分支预测
那到这里可能你就要问了这样看起来不是很好么Intel的CPU支持的指令集很大我们之前说过有2000多条指令。有些指令很简单执行也很快比如无条件跳转指令不需要通过ALU进行任何计算只要更新一下PC寄存器里面的内容就好了。而有些指令很复杂比如浮点数的运算需要进行指数位比较、对齐然后对有效位进行移位然后再进行计算。两者的执行时间相差二三十倍也很正常。
既然这样Pentium 4的超长流水线看起来很合理呀为什么Pentium 4最终成为Intel在技术架构层面的大失败呢
**第一个自然是我们在第3讲里讲过的功耗问题**。提升流水线深度必须要和提升CPU主频同时进行。因为在单个Pipeline Stage能够执行的功能变简单了也就意味着单个时钟周期内能够完成的事情变少了。所以只有提升时钟周期CPU在指令的响应时间这个指标上才能保持和原来相同的性能。
同时,由于流水线深度的增加,我们需要的电路数量变多了,也就是我们所使用的晶体管也就变多了。
主频的提升和晶体管数量的增加都使得我们CPU的功耗变大了。这个问题导致了Pentium 4在整个生命周期里都成为了耗电和散热的大户。而Pentium 4是在20002004年作为Intel的主打CPU出现在市场上的。这个时间段正是笔记本电脑市场快速发展的时间。在笔记本电脑上功耗和散热比起台式机是一个更严重的问题了。即使性能更好别人的笔记本可以用上2小时你的只能用30分钟那谁也不爱买啊
更何况Pentium 4的性能还更差一些。**这个就要我们说到第二点了,就是上面说的流水线技术带来的性能提升,是一个理想情况。在实际的程序执行中,并不一定能够做得到**。
还回到我们刚才举的三条指令的例子。如果这三条指令,是下面这样的三条代码,会发生什么情况呢?
```
int a = 10 + 5; // 指令1
int b = a * 2; // 指令2
float c = b * 1.0f; // 指令3
```
我们会发现指令2不能在指令1的第一个Stage执行完成之后进行。因为指令2依赖指令1的计算结果。同样的指令3也要依赖指令2的计算结果。这样即使我们采用了流水线技术这三条指令执行完成的时间也是 200 + 300 + 600 = 1100 ps而不是之前说的 800ps。而如果指令1和2都是浮点数运算需要600ps。那这个依赖关系会导致我们需要的时间变成1800ps和单指令周期CPU所要花费的时间是一样的。
这个依赖问题,就是我们在计算机组成里面所说的**冒险**Hazard问题。这里我们只列举了在数据层面的依赖也就是数据冒险。在实际应用中还会有**结构冒险、控制冒险**等其他的依赖问题。
对应这些冒险问题,我们也有在**乱序执行**、**分支预测**等相应的解决方案。我们在后面的几讲里面,会详细讲解对应的知识。
但是我们的流水线越长这个冒险的问题就越难一解决。这是因为同一时间同时在运行的指令太多了。如果我们只有3级流水线我们可以把后面没有依赖关系的指令放到前面来执行。这个就是我们所说的乱序执行的技术。比方说我们可以扩展一下上面的3行代码再加上几行代码。
```
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
```
我们可以不先执行1、2、3这三条指令而是在流水线里先执行1、4、7三条指令。这三条指令之间是没有依赖关系的。然后再执行2、5、8以及3、6、9。这样我们又能够充分利用CPU的计算能力了。
但是如果我们有20级流水线意味着我们要确保这20条指令之间没有依赖关系。这个挑战一下子就变大了很多。毕竟我们平时撰写程序通常前后的代码都是有一定的依赖关系的几十条没有依赖关系的指令可不好找。这也是为什么超长流水线的执行效率发而降低了的一个重要原因。
## 总结延伸
相信到这里你对CPU的流水线技术有了一个更加深入的了解。你会发现流水线技术和其他技术一样都讲究一个“折衷”Trade-Off。一个合理的流水线深度会提升我们CPU执行计算机指令的吞吐率。我们一般用IPCInstruction Per Cycle来衡量CPU执行指令的效率。
IPC呢其实就是我们之前在第3讲讲的CPICycle Per Instruction的倒数。也就是说 IPC = 3对应着CPI = 0.33。Pentium 4和Pentium D的IPC都远低于自己上一代的Pentium III以及竞争对手AMD的Athlon CPU。
过深的流水线不仅不能提升计算机指令的吞吐率更会加大计算的功耗和散热问题。Intel自己在笔记本电脑市场也很快放弃了Pentium 4而是主推了使用Pentium III架构的图拉丁CPU。
而流水线带来的吞吐率提升,只是一个理想情况下的理论值。在实践的应用过程中,还需要解决指令之间的依赖问题。这个使得我们的流水线,特别是超长的流水线的执行效率变得很低。要想解决好**冒险**的依赖关系问题,我们需要引入乱序执行、分支预测等技术,这也是我在后面几讲里面要详细讲解的内容。
## 推荐阅读
除了之前的教科书之外,我推荐你读一读[Modern Microprocessors, A 90-Minute Guide!](http://www.lighterra.com/papers/modernmicroprocessors/)这篇文章。这篇文章用比较浅显的方式介绍了现代CPU设计的多个方面很适合作为一个周末读物快速理解现代CPU的设计。
## 课后思考
除了我们这里提到的数据层面的依赖,你能找找我们在程序的执行过程中,其他的依赖情况么?这些依赖情况又属于我们说的哪一种冒险呢?
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,193 @@
<audio id="audio" title="22 | 冒险和预测hazard是“危”也是“机”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/23/f5/2308222ba20e143e4f33ab4c58d588f5.mp3"></audio>
过去两讲我为你讲解了流水线设计CPU所需要的基本概念。接下来我们一起来看看要想通过流水线设计来提升CPU的吞吐率我们需要冒哪些风险。
任何一本讲解CPU的流水线设计的教科书都会提到流水线设计需要解决的三大冒险分别是**结构冒险**Structural Hazard、**数据冒险**Data Hazard以及**控制冒险**Control Hazard
这三大冒险的名字很有意思,它们都叫作**hazard**冒险。喜欢玩游戏的话你应该知道一个著名的游戏生化危机英文名就叫Biohazard。的确hazard还有一个意思就是“危机”。那为什么在流水线设计里hazard没有翻译成“危机”而是要叫“冒险”呢
在CPU的流水线设计里固然我们会遇到各种“危险”情况使得流水线里的下一条指令不能正常运行。但是我们其实还是通过“抢跑”的方式“冒险”拿到了一个提升指令吞吐率的机会。流水线架构的CPU是我们主动进行的冒险选择。我们期望能够通过冒险带来更高的回报所以这不是无奈之下的应对之举自然也算不上什么危机了。
事实上,对于各种冒险可能造成的问题,我们其实都准备好了应对的方案。这一讲里,我们先从结构冒险和数据冒险说起,一起来看看这些冒险及其对应的应对方案。
## 结构冒险:为什么工程师都喜欢用机械键盘?
我们先来看一看结构冒险。结构冒险,本质上是一个硬件层面的资源竞争问题,也就是一个硬件电路层面的问题。
CPU在同一个时钟周期同时在运行两条计算机指令的不同阶段。但是这两个不同的阶段可能会用到同样的硬件电路。
最典型的例子就是内存的数据访问。请你看看下面这张示意图,其实就是[第20讲](https://time.geekbang.org/column/article/99523)里对应的5级流水线的示意图。
可以看到在第1条指令执行到访存MEM阶段的时候流水线里的第4条指令在执行取指令Fetch的操作。访存和取指令都要进行内存数据的读取。我们的内存只有一个地址译码器的作为地址输入那就只能在一个时钟周期里面读取一条数据没办法同时执行第1条指令的读取内存数据和第4条指令的读取指令代码。
<img src="https://static001.geekbang.org/resource/image/c2/4e/c2a4c0340cb835350ea954cdc520704e.jpeg" alt="">
类似的资源冲突,其实你在日常使用计算机的时候也会遇到。最常见的就是薄膜键盘的“锁键”问题。常用的最廉价的薄膜键盘,并不是每一个按键的背后都有一根独立的线路,而是多个键共用一个线路。如果我们在同一时间,按下两个共用一个线路的按键,这两个按键的信号就没办法都传输出去。
这也是为什么,重度键盘用户,都要买贵一点儿的机械键盘或者电容键盘。因为这些键盘的每个按键都有独立的传输线路,可以做到“全键无冲”,这样,无论你是要大量写文章、写程序,还是打游戏,都不会遇到按下了键却没生效的情况。
“全键无冲”这样的资源冲突解决方案,其实本质就是**增加资源**。同样的方案我们一样可以用在CPU的结构冒险里面。对于访问内存数据和取指令的冲突一个直观的解决方案就是把我们的内存分成两部分让它们各有各的地址译码器。这两部分分别是**存放指令的程序内存**和**存放数据的数据内存**。
这样把内存拆成两部分的解决方案,在计算机体系结构里叫作[哈佛架构](https://en.wikipedia.org/wiki/Harvard_architecture)Harvard Architecture来自哈佛大学设计[Mark I型计算机](https://en.wikipedia.org/wiki/Harvard_Mark_I)时候的设计。对应的我们之前说的冯·诺依曼体系结构又叫作普林斯顿架构Princeton Architecture。从这些名字里我们可以看到早年的计算机体系结构的设计其实产生于美国各个高校之间的竞争中。
不过我们今天使用的CPU仍然是冯·诺依曼体系结构的并没有把内存拆成程序内存和数据内存这两部分。因为如果那样拆的话对程序指令和数据需要的内存空间我们就没有办法根据实际的应用去动态分配了。虽然解决了资源冲突的问题但是也失去了灵活性。
<img src="https://static001.geekbang.org/resource/image/e7/91/e7508cb409d398380753b292b6df8391.jpeg" alt="">
不过借鉴了哈佛结构的思路现代的CPU虽然没有在内存层面进行对应的拆分却在CPU内部的高速缓存部分进行了区分把高速缓存分成了**指令缓存**Instruction Cache和**数据缓存**Data Cache两部分。
内存的访问速度远比CPU的速度要慢所以现代的CPU并不会直接读取主内存。它会从主内存把指令和数据加载到高速缓存中这样后续的访问都是访问高速缓存。而指令缓存和数据缓存的拆分使得我们的CPU在进行数据访问和取指令的时候不会再发生资源冲突的问题了。
## 数据冒险:三种不同的依赖关系
结构冒险是一个硬件层面的问题,我们可以靠增加硬件资源的方式来解决。然而还有很多冒险问题,是程序逻辑层面的事儿。其中,最常见的就是数据冒险。
数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们可以分成三大类,分别是**先写后读**Read After WriteRAW、**先读后写**Write After ReadWAR和**写后再写**Write After WriteWAW。下面我们分别看一下这几种情况。
### 先写后读Read After Write
我们先来一起看看先写后读这种情况。这里有一段简单的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。
### 先读后写Write After Read
我们还会面临的另外一种情况,先读后写。我们小小地修改一下代码,先计算 a = b + a然后再计算 b = a + b。
```
int main() {
int a = 1;
int b = 2;
a = b + a;
b = a + b;
}
```
```
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。
### 写后再写Write After Write
我们再次小小地改写上面的代码。这次,我们先设置变量 a = 1然后再设置变量 a = 2。
```
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。
### 再等等:通过流水线停顿解决数据冒险
除了读之后再进行读,你会发现,对于同一个寄存器或者内存地址的操作,都有明确强制的顺序要求。而这个顺序操作的要求,也为我们使用流水线带来了很大的挑战。因为流水线架构的核心,就是在前一个指令还没有结束的时候,后面的指令就要开始执行。
所以,我们需要有解决这些数据冒险的办法。其中最简单的一个办法,不过也是最笨的一个办法,就是[流水线停顿](https://en.wikipedia.org/wiki/Pipeline_stall)Pipeline Stall或者叫流水线冒泡Pipeline Bubbling
流水线停顿的办法很容易理解。如果我们发现了后面执行的指令,会对前面执行的指令有数据层面的依赖关系,那最简单的办法就是“**再等等**”。我们在进行指令译码的时候,会拿到对应指令所需要访问的寄存器和内存地址。所以,在这个时候,我们能够判断出来,这个指令是否会触发数据冒险。如果会触发数据冒险,我们就可以决定,让整个流水线停顿一个或者多个周期。
<img src="https://static001.geekbang.org/resource/image/d1/c8/d1e24e4b18411a5391757a197de2bdc8.jpeg" alt="">
我在前面说过时钟信号会不停地在0和1之前自动切换。其实我们并没有办法真的停顿下来。流水线的每一个操作步骤必须要干点儿事情。所以在实践过程中我们并不是让流水线停下来而是在执行后面的操作步骤前面插入一个NOP操作也就是执行一个其实什么都不干的操作。
<img src="https://static001.geekbang.org/resource/image/0d/2a/0d762f2ce532d87cfe69c7b167af9c2a.jpeg" alt="">
这个插入的指令就好像一个水管Pipeline里面进了一个空的气泡。在水流经过的时候没有传送水到下一个步骤而是给了一个什么都没有的空气泡。这也是为什么我们的流水线停顿又被叫作流水线冒泡Pipeline Bubble的原因。
## 总结延伸
讲到这里,相信你已经弄明白了什么是结构冒险,什么是数据冒险,以及数据冒险所要保障的三种依赖,也就是数据依赖、反依赖以及输出依赖。
一方面我们可以通过增加资源来解决结构冒险问题。我们现代的CPU的体系结构其实也是在冯·诺依曼体系结构下借鉴哈佛结构的一个混合结构的解决方案。我们的内存虽然没有按照功能拆分但是在高速缓存层面进行了拆分也就是拆分成指令缓存和数据缓存这样的方式从硬件层面使得同一个时钟下对于相同资源的竞争不再发生。
另一方面我们也可以通过“等待”也就是插入无效的NOP操作的方式来解决冒险问题。这就是所谓的流水线停顿。不过流水线停顿这样的解决方案是以牺牲CPU性能为代价的。因为实际上在最差的情况下我们的流水线架构的CPU又会退化成单指令周期的CPU了。
所以,下一讲,我们进一步看看,其他更高级的解决数据冒险的方案,以及控制冒险的解决方案,也就是操作数前推、乱序执行和还有分支预测技术。
## 推荐阅读
想要进一步理解流水线冒险里数据冒险的相关知识,你可以仔细看一看《计算机组成与设计:硬件/软件接口》的第4.54.7章。
想要了解流水线冒险里面结构冒险的相关知识你可以去看一看Coursera上普林斯顿大学的Computer Architecture的[Structure Hazard](https://zh.coursera.org/lecture/comparch/structural-hazard-lB2xV)部分。
## 课后思考
在采用流水线停顿的解决方案的时候我们不仅要在当前指令里面插入NOP操作所有后续指令也要插入对应的NOP操作这是为什么呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,96 @@
<audio id="audio" title="23 | 冒险和预测(二):流水线里的接力赛" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/9e/a1158e59b928fa6c6104619fa22c9d9e.mp3"></audio>
上一讲我为你讲解了结构冒险和数据冒险以及应对这两种冒险的两个解决方案。一种方案是增加资源通过添加指令缓存和数据缓存让我们对于指令和数据的访问可以同时进行。这个办法帮助CPU解决了取指令和访问数据之间的资源冲突。另一种方案是直接进行等待。通过插入NOP这样的无效指令等待之前的指令完成。这样我们就能解决不同指令之间的数据依赖问题。
着急的人,看完上一讲的这两种方案,可能已经要跳起来问了:“这也能算解决方案么?”的确,这两种方案都有点儿笨。
第一种解决方案,好比是在软件开发的过程中,发现效率不够,于是研发负责人说:“我们需要双倍的人手和研发资源。”而第二种解决方案,好比你在提需求的时候,研发负责人告诉你说:“来不及做,你只能等我们需求排期。” 你应该很清楚地知道,“堆资源”和“等排期”这样的解决方案,并不会真的提高我们的效率,只是避免冲突的无奈之举。
那针对流水线冒险的问题,我们有没有更高级或者更高效的解决方案呢?既不用简单花钱加硬件电路这样“堆资源”,也不是纯粹等待之前的任务完成这样“等排期”。
答案当然是有的。这一讲,我们就来看看计算机组成原理中,一个更加精巧的解决方案,**操作数前推**。
## NOP操作和指令对齐
要想理解操作数前推技术,我们先来回顾一下,[第5讲](https://time.geekbang.org/column/article/93359)讲过的MIPS体系结构下的R、I、J三类指令以及[第20讲](https://time.geekbang.org/column/article/99523)里的五级流水线“取指令IF-指令译码ID-指令执行EX-内存访问MEM-数据写回WB ”。
我把对应的图片放进来了,你可以看一下。如果印象不深,建议你先回到这两节去复习一下,再来看今天的内容。
<img src="https://static001.geekbang.org/resource/image/b1/bf/b1ade5f8de67b172bf7b4ec9f63589bf.jpeg" alt=""><img src="https://static001.geekbang.org/resource/image/1e/ad/1e880fa8b1eab511583267e68f0541ad.jpeg" alt="">
在MIPS的体系结构下不同类型的指令会在流水线的不同阶段进行不同的操作。
我们以MIPS的LOAD这样从内存里读取数据到寄存器的指令为例来仔细看看它需要经历的5个完整的流水线。STORE这样从寄存器往内存里写数据的指令不需要有写回寄存器的操作也就是没有数据写回的流水线阶段。至于像ADD和SUB这样的加减法指令所有操作都在寄存器完成所以没有实际的内存访问MEM操作。
<img src="https://static001.geekbang.org/resource/image/b6/d4/b66ea9ca3300c7f71e91aaa6b6428fd4.jpg" alt="">
有些指令没有对应的流水线阶段但是我们并不能跳过对应的阶段直接执行下一阶段。不然如果我们先后执行一条LOAD指令和一条ADD指令就会发生LOAD指令的WB阶段和ADD指令的WB阶段在同一个时钟周期发生。这样相当于触发了一个结构冒险事件产生了资源竞争。
<img src="https://static001.geekbang.org/resource/image/9e/5f/9e62ab3b42e445d65accf0549badf45f.jpeg" alt="">
所以在实践当中各个指令不需要的阶段并不会直接跳过而是会运行一次NOP操作。通过插入一个NOP操作我们可以使后一条指令的每一个Stage一定不和前一条指令的同Stage在一个时钟周期执行。这样就不会发生先后两个指令在同一时钟周期竞争相同的资源产生结构冒险了。
<img src="https://static001.geekbang.org/resource/image/c1/42/c16643d83dd534d3d97d0d7ad8e30d42.jpg" alt="">
## 流水线里的接力赛:操作数前推
通过NOP操作进行对齐我们在流水线里就不会遇到资源竞争产生的结构冒险问题了。除了可以解决结构冒险之外这个NOP操作也是我们之前讲的流水线停顿插入的对应操作。
但是插入过多的NOP操作意味着我们的CPU总是在空转干吃饭不干活。那么我们有没有什么办法尽量少插入一些NOP操作呢不要着急下面我们就以两条先后发生的ADD指令作为例子看看能不能找到一些好的解决方案。
```
add $t0, $s2,$s1
add $s2, $s1,$t0
```
这两条指令很简单。
1. 第一条指令,把 s1 和 s2 寄存器里面的数据相加,存入到 t0 这个寄存器里面。
1. 第二条指令,把 s1 和 t0 寄存器里面的数据相加,存入到 s2 这个寄存器里面。
因为后一条的 add 指令,依赖寄存器 t0 里的值。而 t0 里面的值又来自于前一条指令的计算结果。所以后一条指令需要等待前一条指令的数据写回阶段完成之后才能执行。就像上一讲里讲的那样我们遇到了一个数据依赖类型的冒险。于是我们就不得不通过流水线停顿来解决这个冒险问题。我们要在第二条指令的译码阶段之后插入对应的NOP指令直到前一天指令的数据写回完成之后才能继续执行。
这样的方案虽然解决了数据冒险的问题但是也浪费了两个时钟周期。我们的第2条指令其实就是多花了2个时钟周期运行了两次空转的NOP操作。
<img src="https://static001.geekbang.org/resource/image/94/69/94dda2330b07c08530540ae11838c569.jpeg" alt="">
不过,其实我们第二条指令的执行,未必要等待第一条指令写回完成,才能进行。如果我们第一条指令的执行结果,能够直接传输给第二条指令的执行阶段,作为输入,那我们的第二条指令,就不用再从寄存器里面,把数据再单独读出来一次,才来执行代码。
我们完全可以在第一条指令的执行阶段完成之后直接将结果数据传输给到下一条指令的ALU。然后下一条指令不需要再插入两个NOP阶段就可以继续正常走到执行阶段。
<img src="https://static001.geekbang.org/resource/image/dc/27/dceadd35c334974d8270052b37d48c27.jpeg" alt="">
这样的解决方案,我们就叫作**操作数前推**Operand Forwarding或者操作数旁路Operand Bypassing。其实我觉得更合适的名字应该叫**操作数转发**。这里的Forward其实就是我们写Email时的“转发”Forward的意思。不过现有的经典教材的中文翻译一般都叫“前推”我们也就不去纠正这个说法了你明白这个意思就好。
转发,其实是这个技术的**逻辑含义**也就是在第1条指令的执行结果直接“转发”给了第2条指令的ALU作为输入。另外一个名字旁路Bypassing则是这个技术的**硬件含义**。为了能够实现这里的“转发”我们在CPU的硬件里面需要再单独拉一根信号传输的线路出来使得ALU的计算结果能够重新回到ALU的输入里来。这样的一条线路就是我们的“旁路”。它越过Bypass了写入寄存器再从寄存器读出的过程也为我们节省了2个时钟周期。
操作数前推的解决方案不但可以单独使用,还可以和流水线冒泡一起使用。有的时候,虽然我们可以把操作数转发到下一条指令,但是下一条指令仍然需要停顿一个时钟周期。
比如说我们先去执行一条LOAD指令再去执行ADD指令。LOAD指令在访存阶段才能把数据读取出来所以下一条指令的执行阶段需要在访存阶段完成之后才能进行。
<img src="https://static001.geekbang.org/resource/image/49/2d/49f3a9b1ae2972ac5c6cfca7731bf12d.jpeg" alt="">
总的来说,操作数前推的解决方案,比流水线停顿更进了一步。流水线停顿的方案,有点儿像游泳比赛的接力方式。下一名运动员,需要在前一个运动员游玩了全程之后,触碰到了游泳池壁才能出发。而操作数前推,就好像短跑接力赛。后一个运动员可以提前抢跑,而前一个运动员会多跑一段主动把交接棒传递给他。
## 总结延伸
这一讲,我给你介绍了一个更加高级,也更加复杂的解决数据冒险问题方案,就是操作数前推,或者叫操作数旁路。
操作数前推就是通过在硬件层面制造一条旁路让一条指令的计算结果可以直接传输给下一条指令而不再需要“指令1写回寄存器指令2再读取寄存器“这样多此一举的操作。这样直接传输带来的好处就是后面的指令可以减少甚至消除原本需要通过流水线停顿才能解决的数据冒险问题。
这个前推的解决方案,不仅可以单独使用,还可以和前面讲解过的流水线冒泡结合在一起使用。因为有些时候,我们的操作数前推并不能减少所有“冒泡”,只能去掉其中的一部分。我们仍然需要通过插入一些“气泡”来解决冒险问题。
通过操作数前推我们进一步提升了CPU的运行效率。那么我们是不是还能找到别的办法进一步地减少浪费呢毕竟看到现在我们仍然少不了要插入很多NOP的“气泡”。那就请你继续坚持学习下去。下一讲我们来看看CPU是怎么通过乱序执行进一步减少“气泡”的。
## 推荐阅读
想要深入了解操作数前推相关的内容,推荐你读一下《计算机组成与设计:硬件/软件接口》的4.54.7章节。
## 课后思考
前面讲5级流水线指令的时候我们说STORE指令是没有数据写回阶段的而ADD指令是没有访存阶段的。那像CMP或者JMP这样的比较和跳转指令5个阶段都是全的么还是说不需要哪些阶段呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,107 @@
<audio id="audio" title="24 | 冒险和预测CPU里的“线程池”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/98/a4dcdc1ec28e6d1a8ea9a34246742f98.mp3"></audio>
过去两讲,我为你讲解了通过增加资源、停顿等待以及主动转发数据的方式,来解决结构冒险和数据冒险问题。对于结构冒险,由于限制来自于同一时钟周期不同的指令,要访问相同的硬件资源,解决方案是增加资源。对于数据冒险,由于限制来自于数据之间的各种依赖,我们可以提前把数据转发到下一个指令。
但是即便综合运用这三种技术我们仍然会遇到不得不停下整个流水线等待前面的指令完成的情况也就是采用流水线停顿的解决方案。比如说上一讲里最后给你的例子即使我们进行了操作数前推因为第二条加法指令依赖于第一条指令从内存中获取的数据我们还是要插入一次NOP的操作。
<img src="https://static001.geekbang.org/resource/image/49/2d/49f3a9b1ae2972ac5c6cfca7731bf12d.jpeg" alt="">
那这个时候你就会想了,那我们能不能让后面没有数据依赖的指令,在前面指令停顿的时候先执行呢?
答案当然是可以的。毕竟,流水线停顿的时候,对应的电路闲着也是闲着。那我们完全可以先完成后面指令的执行阶段。
## 填上空闲的NOP上菜的顺序不必是点菜的顺序
之前我为你讲解的,无论是流水线停顿,还是操作数前推,归根到底,只要前面指令的特定阶段还没有执行完成,后面的指令就会被“阻塞”住。
但是这个“阻塞”很多时候是没有必要的。因为尽管你的代码生成的指令是顺序的,但是如果后面的指令不需要依赖前面指令的执行结果,完全可以不必等待前面的指令运算完成。
比如说,下面这三行代码。
```
a = b + c
d = a * e
x = y * z
```
计算里面的 x ,却要等待 a 和 d 都计算完成,实在没啥必要。所以我们完全可以在 d 的计算等待 a 的计算的过程中,先把 x 的结果给算出来。
在流水线里,后面的指令不依赖前面的指令,那就不用等待前面的指令执行,它完全可以先执行。
<img src="https://static001.geekbang.org/resource/image/37/ef/37ba6c453e530660cecbbfcf56a3ecef.jpeg" alt="">
可以看到,因为第三条指令并不依赖于前两条指令的计算结果,所以在第二条指令等待第一条指令的访存和写回阶段的时候,第三条指令就已经执行完成了。
这就好比你开了一家餐馆,顾客会排队来点菜。餐馆的厨房里会有洗菜、切菜、炒菜、上菜这样的各个步骤。后厨也是按照点菜的顺序开始做菜的。但是不同的菜需要花费的时间和工序可能都有差别。有些菜做起来特别麻烦,特别慢。比如做一道佛跳墙有好几道工序。我们没有必要非要等先点的佛跳墙上菜了,再开始做后面的炒鸡蛋。只要有厨子空出来了,就可以先动手做前面的简单菜,先给客户端上去。
这样的解决方案,在计算机组成里面,被称为**乱序执行**Out-of-Order ExecutionOoOE。乱序执行最早来自于著名的IBM 360。相信你一定听说过《人月神话》这本软件工程届的经典著作它讲的就是IBM 360开发过程中的“人生体会”。而IBM 360困难的开发过程也少不了第一次引入乱序执行这个新的CPU技术。
## CPU里的“线程池”理解乱序执行
那么我们的CPU怎样才能实现乱序执行呢是不是像玩俄罗斯方块一样把后面的指令找一个前面的坑填进去就行了事情并没有这么简单。其实从今天软件开发的维度来思考乱序执行好像是在指令的执行阶段引入了一个“线程池”。我们下面就来看一看在CPU里乱序执行的过程究竟是怎样的。
使用乱序执行技术后CPU里的流水线就和我之前给你看的5级流水线不太一样了。我们一起来看一看下面这张图。
<img src="https://static001.geekbang.org/resource/image/15/04/153f8d5e4a4363399133e1d7d9052804.jpeg" alt="">
1.在取指令和指令译码的时候乱序执行的CPU和其他使用流水线架构的CPU是一样的。它会一级一级顺序地进行取指令和指令译码的工作。
2.在指令译码完成之后就不一样了。CPU不会直接进行指令执行而是进行一次指令分发把指令发到一个叫作保留站Reservation Stations的地方。顾名思义这个保留站就像一个火车站一样。发送到车站的指令就像是一列列的火车。
3.这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。这就好像一列列的火车都要等到乘客来齐了才能出发。
4.一旦指令依赖的数据来齐了指令就可以交到后面的功能单元Function UnitFU其实就是ALU去执行了。我们有很多功能单元可以并行运行但是不同的功能单元能够支持执行的指令并不相同。就和我们的铁轨一样有些从上海北上可以到北京和哈尔滨有些是南下的可以到广州和深圳。
5.指令执行的阶段完成之后我们并不能立刻把结果写回到寄存器里面去而是把结果再存放到一个叫作重排序缓冲区Re-Order BufferROB的地方。
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 -&gt; d -&gt; x而计算完成的顺序是 x -&gt; a -&gt; d。
在这整个过程中整个计算乘法的FU都没有闲置这也意味着我们的CPU的吞吐率最大化了。
整个乱序执行技术,就好像在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行。
乱序执行极大地提高了CPU的运行效率。核心原因是现代CPU的运行速度比访问主内存的速度要快很多。如果完全采用顺序执行的方式很多时间都会浪费在前面指令等待获取内存数据的时间里。CPU不得不加入NOP操作进行空转。而现代CPU的流水线级数也已经相对比较深了到达了14级。这也意味着同一个时钟周期内并行执行的指令数是很多的。
而乱序执行以及我们后面要讲的高速缓存弥补了CPU和内存之间的性能差异。同样也充分利用了较深的流水行带来的并发性使得我们可以充分利用CPU的性能。
## 总结延伸
好了,总结一下。这一讲里,我为你介绍了乱序执行,这个解决流水线阻塞的技术方案。因为数据的依赖关系和指令先后执行的顺序问题,很多时候,流水线不得不“阻塞”在特定的指令上。即使后续别的指令,并不依赖正在执行的指令和阻塞的指令,也不能继续执行。
而乱序执行则是在指令执行的阶段通过一个类似线程池的保留站让系统自己去动态调度先执行哪些指令。这个动态调度巧妙地解决了流水线阻塞的问题。指令执行的先后顺序不再和它们在程序中的顺序有关。我们只要保证不破坏数据依赖就好了。CPU只要等到在指令结果的最终提交的阶段再通过重排序的方式确保指令“实际上”是顺序执行的。
## 推荐阅读
想要更深入地了解CPU的乱序执行的知识我们就不能局限于组成原理而要深入到体系结构中去了。你可以读一下《计算机体系结构量化研究方法》的3.4和3.5章节。
想要了解乱序执行为什么可行你可以看看Wikipedia上乱序执行所依赖的[Tomasulo算法](https://en.wikipedia.org/wiki/Tomasulo_algorithm)。这个算法也是在IBM 360时代引入的。
## 课后思考
在现代Intel的CPU的乱序执行的过程中只有指令的执行阶段是乱序的后面的内存访问和数据写回阶段都仍然是顺序的。这种保障内存数据访问顺序的模型叫作强内存模型Strong Memory Model。你能想一想我们为什么要保障内存访问的顺序呢在前后执行的指令没有相关数据依赖的情况下为什么我们仍然要求这个顺序呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,158 @@
<audio id="audio" title="25 | 冒险和预测(四):今天下雨了,明天还会下雨么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ff/47/ffc3801e7b380250aef34b2c8fd6e847.mp3"></audio>
过去三讲,我主要为你介绍了结构冒险和数据冒险,以及增加资源、流水线停顿、操作数前推、乱序执行,这些解决各种“冒险”的技术方案。
在结构冒险和数据冒险中,你会发现,所有的流水线停顿操作都要从**指令执行阶段**开始。流水线的前两个阶段也就是取指令IF和指令译码ID的阶段是不需要停顿的。CPU会在流水线里面直接去取下一条指令然后进行译码。
取指令和指令译码不会需要遇到任何停顿,这是基于一个假设。这个假设就是,所有的指令代码都是顺序加载执行的。不过这个假设,在执行的代码中,一旦遇到 if…else 这样的条件分支,或者 for/while 循环,就会不成立。
<img src="https://static001.geekbang.org/resource/image/b4/fa/b439cebb2d85496ad6eef2f61071aefa.jpeg" alt="">
我们先来回顾一下,[第6讲](https://time.geekbang.org/column/article/94075)里讲的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需要提供对应的丢弃指令的功能通过控制信号清除掉已经在流水线中执行的指令。只要对应的清除开销不要太大我们就是划得来的。
<img src="https://static001.geekbang.org/resource/image/39/c3/39d114b3e37fe7fbad98ef0322b876c3.jpeg" alt="">
### 动态分支预测
第三个办法,叫作**动态分支预测**。
上面的静态预测策略看起来比较简单预测的准确率也许有50%。但是如果运气不好,可能就会特别差。于是,工程师们就开始思考,我们有没有更好的办法呢?比如,根据之前条件跳转的比较结果来预测,是不是会更准一点?
我们日常生活里,最经常会遇到的预测就是天气预报。如果没有气象台给你天气预报,你想要猜一猜明天是不是下雨,你会怎么办?
有一个简单的策略,就是完全根据今天的天气来猜。如果今天下雨,我们就预测明天下雨。如果今天天晴,就预测明天也不会下雨。这是一个很符合我们日常生活经验的预测。因为一般下雨天,都是连着下几天,不断地间隔地发生“天晴-下雨-天晴-下雨”的情况并不多见。
那么把这样的实践拿到生活中来是不是有效呢我在这里给了一张2019年1月上海的天气情况的表格。
<img src="https://static001.geekbang.org/resource/image/2f/d8/2f83d82e417f1d37cb9ddb253a0b6cd8.png" alt="">
我们用前一天的是不是下雨直接来预测后一天会不会下雨。这个表格里一共有31天那我们就可以预测30次。你可以数一数按照这种预测方式我们可以预测正确23次正确率是76.7%比随机预测的50%要好上不少。
而同样的策略,我们一样可以放在分支预测上。这种策略,我们叫**一级分支预测**One Level Branch Prediction或者叫**1比特饱和计数**1-bit saturating counter。这个方法其实就是用一个比特去记录当前分支的比较情况直接用当前分支的比较情况来预测下一次分支时候的比较情况。
只用一天下雨,就预测第二天下雨,这个方法还是有些“草率”,我们可以用更多的信息,而不只是一次的分支信息来进行预测。于是,我们可以引入一个**状态机**State Machine来做这个事情。
如果连续发生下雨的情况,我们就认为更有可能下雨。之后如果只有一天放晴了,我们仍然认为会下雨。在连续下雨之后,要连续两天放晴,我们才会认为之后会放晴。整个状态机的流转,可以参考我在文稿里放的图。
<img src="https://static001.geekbang.org/resource/image/ea/5d/ea82f279b48c10ad95027c91ed62ab5d.jpeg" alt="">
这个状态机里我们一共有4个状态所以我们需要2个比特来记录对应的状态。这样这整个策略就可以叫作**2比特饱和计数**,或者叫**双模态预测器**Bimodal Predictor
好了现在你可以用这个策略再去对照一下上面的天气情况。如果天气的初始状态我们放在“多半放晴”的状态下我们预测的结果的正确率会是22次也就是73.3%的正确率。可以看到,并不是更复杂的算法,效果一定就更好。实际的预测效果,和实际执行的指令高度相关。
如果想对各种分支预测技术有所了解,[Wikipedia](https://en.wikipedia.org/wiki/Branch_predictor)里面有更详细的内容和更多的分支预测算法,你可以看看。
## 为什么循环嵌套的改变会影响性能?
说完了分支预测现在我们先来看一个Java程序。
```
public class BranchPrediction {
public static void main(String args[]) {
long start = System.currentTimeMillis();
for (int i = 0; i &lt; 100; i++) {
for (int j = 0; j &lt;1000; j ++) {
for (int k = 0; k &lt; 10000; k++) {
}
}
}
long end = System.currentTimeMillis();
System.out.println(&quot;Time spent is &quot; + (end - start));
start = System.currentTimeMillis();
for (int i = 0; i &lt; 10000; i++) {
for (int j = 0; j &lt;1000; j ++) {
for (int k = 0; k &lt; 100; k++) {
}
}
}
end = System.currentTimeMillis();
System.out.println(&quot;Time spent is &quot; + (end - start) + &quot;ms&quot;);
}
}
```
这是一个简单的三重循环里面没有任何逻辑代码。我们用两种不同的循环顺序各跑一次。第一次最外重循环循环了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这样先比较后跳转的指令来实现的。如果对for循环的汇编代码或者机器代码的实现不太清楚你可以回头去复习一下第6讲。
这里的代码每一次循环都有一个cmp和jle指令。每一个 jle 就意味着,要比较条件码寄存器的状态,决定是顺序执行代码,还是要跳转到另外一个地址。也就是说,在每一次循环发生的时候,都会有一次“分支”。
<img src="https://static001.geekbang.org/resource/image/69/a5/69c0cb32d5b7139e0f993855104e55a5.jpeg" alt="">
分支预测策略最简单的一个方式,自然是“**假定分支不发生**”。对应到上面的循环代码,就是循环始终会进行下去。在这样的情况下,上面的第一段循环,也就是内层 k 循环10000次的代码。每隔10000次才会发生一次预测上的错误。而这样的错误在第二层 j 的循环发生的次数是1000次。
最外层的 i 的循环是100次。每个外层循环一次里面都会发生1000次最内层 k 的循环的预测错误,所以一共会发生 100 × 1000 = 10万次预测错误。
上面的第二段循环也就是内存k的循环100次的代码则是每100次循环就会发生一次预测错误。这样的错误在第二层j的循环发生的次数还是1000次。最外层 i 的循环是10000次所以一共会发生 1000 × 10000 = 1000万次预测错误。
到这里,相信你能猜到为什么同样空转次数相同的循环代码,第一段代码运行的时间要少得多了。因为第一段代码发生“分支预测”错误的情况比较少,更多的计算机指令,在流水线里顺序运行下去了,而不需要把运行到一半的指令丢弃掉,再去重新加载新的指令执行。
## 总结延伸
好了,这一讲,我给你讲解了什么是控制冒险,以及应对控制冒险的三个方式。
第一种方案类似我们的操作数前推其实是在改造我们的CPU功能通过增加对应的电路的方式来缩短分支带来的延迟。另外两种解决方案无论是“假装分支不发生”还是“动态分支预测”其实都是在进行“分支预测”。只是“假装分支不发生”是一种简单的静态预测方案而已。
在动态分支预测技术里我给你介绍了一级分支预测或者叫1比特饱和计数的方法。其实就是认为预测结果和上一次的条件跳转是一致的。在此基础上我还介绍了利用更多信息的就是2比特饱和计数或者叫双模态预测器的方法。这个方法其实也只是通过一个状态机多看了一步过去的跳转比较结果。
这个方法虽然简单,但是却非常有效。在 SPEC 89 版本的测试当中使用这样的饱和计数方法预测的准确率能够高达93.5%。Intel的CPU一直到Pentium时代在还没有使用MMX指令集的时候用的就是这种分支预测方式。
这一讲的最后,我给你看了一个有意思的例子。通过交换内外循环的顺序,我们体验了一把控制冒险导致的性能差异。虽然执行的指令数是一样的,但是分支预测失败得多的程序,性能就要差上几倍。
## 推荐阅读
想要进一步了解控制冒险和分支预测技术,可以去读一读《计算机组成与设计:硬件/软件接口》的4.8章节。
如果想对各种分支预测技术有所了解,[Wikipedia](https://en.wikipedia.org/wiki/Branch_predictor)里面有更详细的内容和更多的分支预测算法。
## 课后思考
我在上面用一个三重循环的Java程序验证了“分支预测”出错会对程序带来的性能影响。你可以用你自己惯用的语言来试一试看一看是否会有同样的效果。如果没有的话原因是什么呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,109 @@
<audio id="audio" title="26 | Superscalar和VLIW如何让CPU的吞吐率超过1" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c4/00/c405ea4f9d4db9086eadac7b74e5c000.mp3"></audio>
到今天为止专栏已经过半了。过去的20多讲里我给你讲的内容很多都是围绕着怎么提升CPU的性能这个问题展开的。
我们先回顾一下[第4讲](https://time.geekbang.org/column/article/93246),不知道你是否还记得这个公式:
这个公式里有一个叫CPI的指标。我们知道CPI的倒数又叫作IPCInstruction Per Clock也就是一个时钟周期里面能够执行的指令数代表了CPU的吞吐率。那么这个指标放在我们前面几节反复优化流水线架构的CPU里能达到多少呢
答案是最佳情况下IPC也只能到1。因为无论做了哪些流水线层面的优化即使做到了指令执行层面的乱序执行CPU仍然只能在一个时钟周期里面取一条指令。
<img src="https://static001.geekbang.org/resource/image/dd/13/dd88d0dbf3a88b09d5e8fb6d9e3aea13.jpeg" alt="">
这说明无论指令后续能优化得多好一个时钟周期也只能执行完这样一条指令CPI只能是1。但是我们现在用的Intel CPU或者ARM的CPU一般的CPI都能做到2以上这是怎么做到的呢
今天我们就一起来看看现代CPU都使用了什么“黑科技”。
## 多发射与超标量:同一时间执行的两条指令
之前讲CPU的硬件组成的时候我们把所有算术和逻辑运算都抽象出来变成了一个ALU这样的“黑盒子”。你应该还记得第13讲到第16讲关于加法器、乘法器、乃至浮点数计算的部分其实整数的计算和浮点数的计算过程差异还是不小的。实际上整数和浮点数计算的电路在CPU层面也是分开的。
一直到80386我们的CPU都是没有专门的浮点数计算的电路的。当时的浮点数计算都是用软件进行模拟的。所以在80386时代Intel给386配了单独的387芯片专门用来做浮点数运算。那个时候你买386芯片的话会有386sx和386dx这两种芯片可以选择。386dx就是带了387浮点数计算芯片的而sx就是不带浮点数计算芯片的。
其实我们现在用的Intel CPU芯片也是一样的。虽然浮点数计算已经变成CPU里的一部分但并不是所有计算功能都在一个ALU里面真实的情况是我们会有多个ALU。这也是为什么在[第24讲](https://time.geekbang.org/column/article/101436)讲乱序执行的时候你会看到其实指令的执行阶段是由很多个功能单元FU并行Parallel进行的。
不过在指令乱序执行的过程中我们的取指令IF和指令译码ID部分并不是并行进行的。
既然指令的执行层面可以并行进行,为什么取指令和指令译码不行呢?如果想要实现并行,该怎么办呢?
其实只要我们把取指令和指令译码也一样通过增加硬件的方式并行进行就好了。我们可以一次性从内存里面取出多条指令然后分发给多个并行的指令译码器进行译码然后对应交给不同的功能单元去处理。这样我们在一个时钟周期里能够完成的指令就不只一条了。IPC也就能做到大于1了。
<img src="https://static001.geekbang.org/resource/image/85/32/85f15ec667d09fd2d368822904029b32.jpeg" alt="">
这种CPU设计我们叫作**多发射**Mulitple Issue和**超标量**Superscalar
什么叫多发射呢这个词听起来很抽象其实它意思就是说我们同一个时间可能会同时把多条指令发射Issue到不同的译码器或者后续处理的流水线中去。
在超标量的CPU里面有很多条并行的流水线而不是只有一条流水线。“超标量“这个词是说本来我们在一个时钟周期里面只能执行一个标量Scalar的运算。在多发射的情况下我们就能够超越这个限制同时进行多次计算。
<img src="https://static001.geekbang.org/resource/image/2e/d3/2e96fe0985a4ae3bd7a58c345def29d3.jpeg" alt="">
你可以看我画的这个超标量设计的流水线示意图。仔细看你应该能看到一个有意思的现象每一个功能单元的流水线的长度是不同的。事实上不同的功能单元的流水线长度本来就不一样。我们平时所说的14级流水线指的通常是进行整数计算指令的流水线长度。如果是浮点数运算实际的流水线长度则会更长一些。
## Intel的失败之作安腾的超长指令字设计
无论是之前几讲里讲的乱序执行还是现在更进一步的超标量技术在实际的硬件层面其实实施起来都挺麻烦的。这是因为在乱序执行和超标量的体系里面我们的CPU要解决依赖冲突的问题。这也就是前面几讲我们讲的冒险问题。
CPU需要在指令执行之前去判断指令之间是否有依赖关系。如果有对应的依赖关系指令就不能分发到执行阶段。因为这样上面我们所说的超标量CPU的多发射功能又被称为**动态多发射处理器**。这些对于依赖关系的检测都会使得我们的CPU电路变得更加复杂。
于是,计算机科学家和工程师们就又有了一个大胆的想法。我们能不能不把分析和解决依赖关系的事情,放在硬件里面,而是放到软件里面来干呢?
如果你还记得的话我在第4讲也讲过要想优化CPU的执行时间关键就是拆解这个公式
当时我们说过这个公式里面我们可以通过改进编译器来优化指令数这个指标。那接下来我们就来看看一个非常大胆的CPU设计想法叫作**超长指令字设计**Very Long Instruction WordVLIW。这个设计呢不仅想让编译器来优化指令数还想直接通过编译器来优化CPI。
围绕着这个设计的是Intel一个著名的“史诗级”失败也就是著名的IA-64架构的安腾Itanium处理器。只不过这一次责任不全在Intel还要拉上可以称之为硅谷起源的另一家公司也就是惠普。
之所以称为“史诗”级失败,这个说法来源于惠普最早给这个架构取的名字,**显式并发指令运算**Explicitly Parallel Instruction Computer这个名字的缩写**EPIC**,正好是“史诗”的意思。
好巧不巧安腾处理器和和我之前给你介绍过的Pentium 4一样在市场上是一个失败的产品。在经历了12年之久的设计研发之后安腾一代只卖出了几千套。而安腾二代在从2002年开始反复挣扎了16年之后最终在2018年被Intel宣告放弃退出了市场。自此世上再也没有这个“史诗”服务器了。
那么,我们就来看看,这个超长指令字的安腾处理器是怎么回事儿。
在乱序执行和超标量的CPU架构里指令的前后依赖关系是由CPU内部的硬件电路来检测的。而到了**超长指令字**的架构里面,这个工作交给了编译器这个软件。
<img src="https://static001.geekbang.org/resource/image/22/de/22b3f723ceee5950ac20a7b874dabbde.jpeg" alt="">
我从专栏第5讲开始就给你看了不少C代码到汇编代码和机器代码的对照。编译器在这个过程中其实也能够知道前后数据的依赖。于是我们可以让编译器把没有依赖关系的代码位置进行交换。然后再把多条连续的指令打包成一个指令包。安腾的CPU就是把3条指令变成一个指令包。
<img src="https://static001.geekbang.org/resource/image/f1/f6/f16a1ae443418caca0dc2fc3cec200f6.jpeg" alt="">
CPU在运行的时候不再是取一条指令而是取出一个指令包。然后译码解析整个指令包解析出3条指令直接并行运行。可以看到使用**超长指令字**架构的CPU同样是采用流水线架构的。也就是说一组Group指令仍然要经历多个时钟周期。同样的下一组指令并不是等上一组指令执行完成之后再执行而是在上一组指令的指令译码阶段就开始取指令了。
值得注意的一点是,流水线停顿这件事情在**超长指令字**里面,很多时候也是由编译器来做的。除了停下整个处理器流水线,**超长指令字**的CPU不能在某个时钟周期停顿一下等待前面依赖的操作执行完成。编译器需要在适当的位置插入NOP操作直接在编译出来的机器码里面就把流水线停顿这个事情在软件层面就安排妥当。
虽然安腾的设想很美好Intel也曾经希望能够让安腾架构成为替代x86的新一代架构但是最终安腾还是在前前后后折腾将近30年后失败了。2018年Intel宣告安腾9500会在2021年停止供货。
安腾失败的原因有很多,其中有一个重要的原因就是“向前兼容”。
一方面安腾处理器的指令集和x86是不同的。这就意味着原来x86上的所有程序是没有办法在安腾上运行的而需要通过编译器重新编译才行。
另一方面安腾处理器的VLIW架构决定了如果安腾需要提升并行度就需要增加一个指令包里包含的指令数量比方说从3个变成6个。一旦这么做了虽然同样是VLIW架构同样指令集的安腾CPU程序也需要重新编译。因为原来编译器判断的依赖关系是在3个指令以及由3个指令组成的指令包之间现在要变成6个指令和6个指令组成的指令包。编译器需要重新编译交换指令顺序以及NOP操作才能满足条件。甚至我们需要重新来写编译器才能让程序在新的CPU上跑起来。
于是安腾就变成了一个既不容易向前兼容又不容易向后兼容的CPU。那么它的失败也就不足为奇了。
可以看到技术思路上的先进想法在实际的业界应用上会遇到更多具体的实践考验。无论是指令集向前兼容性还是对应CPU未来的扩展在设计的时候都需要更多地去考虑实践因素。
## 总结延伸
这一讲里我和你一起向CPU的性能发起了一个新的挑战让CPU的吞吐率也就是IPC能够超过1。
我先是为你介绍了超标量也就是Superscalar这个方法。超标量可以让CPU不仅在指令执行阶段是并行的在取指令和指令译码的时候也是并行的。通过超标量技术可以使得你所使用的CPU的IPC超过1。
在Intel的x86的CPU里从Pentium时代第一次开始引入超标量技术整个CPU的性能上了一个台阶。对应的技术一直沿用到了现在。超标量技术和你之前看到的其他流水线技术一样依赖于在硬件层面能够检测到对应的指令的先后依赖关系解决“冒险”问题。所以它也使得CPU的电路变得更复杂了。
因为这些复杂性惠普和Intel又共同推出了著名的安腾处理器。通过在编译器层面直接分析出指令的前后依赖关系。于是硬件在代码编译之后就可以直接拿到调换好先后顺序的指令。并且这些指令中可以并行执行的部分会打包在一起组成一个指令包。安腾处理器在取指令和指令译码的时候拿到的不再是单个指令而是这样一个指令包。并且在指令执行阶段可以并行执行指令包里所有的指令。
虽然看起来VLIW在技术层面更具有颠覆性不仅仅只是一个硬件层面的改造而且利用了软件层面的编译器来组合解决提升CPU指令吞吐率的问题。然而最终VLIW却没有得到市场和业界的认可。
惠普和Intel强强联合开发的安腾处理器命运多舛。从1989开始研发直到2001年才发布了第一代安腾处理器。然而12年的开发过程后第一代安腾处理器最终只卖出了几千套。而2002年发布的安腾2处理器也没能拯救自己的命运。最终在2018年Intel宣布安腾退出市场。自此之后市面上再没有能够大规模商用的VLIW架构的处理器了。
## 推荐阅读
关于超标量和多发射的相关知识,你可以多看一看《计算机组成与设计:硬件/软件接口》的4.10部分。其中4.10.1和4.10.2的推测和静态多发射其实就是今天我们讲的超长指令字VLIW的知识点。4.10.2的动态多发射其实就是今天我们讲的超标量Superscalar的知识点。
## 课后思考
在超长指令字架构的CPU里面我之前给你讲到的各种应对流水线冒险的方案还是有效的么操作数前推、乱序执行分支预测能用在这样的体系架构下么安腾CPU里面是否有用到这些相关策略呢
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,105 @@
<audio id="audio" title="27 | SIMD如何加速矩阵乘法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/4d/50510dc79e2000f84bf57d941b75bc4d.mp3"></audio>
上一讲里呢我进一步为你讲解了CPU里的“黑科技”分别是超标量Superscalar技术和超长指令字VLIW技术。
超标量Superscalar技术能够让取指令以及指令译码也并行进行在编译的过程超长指令字VLIW技术可以搞定指令先后的依赖关系使得一次可以取一个指令包。
不过CPU里的各种神奇的优化我们还远远没有说完。这一讲里我就带你一起来看看专栏里最后两个提升CPU性能的架构设计。它们分别是你应该常常听说过的**超线程**Hyper-Threading技术以及可能没有那么熟悉的**单指令多数据流**SIMD技术。
## 超线程Intel多卖给你的那一倍CPU
不知道你是不是还记得,在[第21讲](https://time.geekbang.org/column/article/100554)我给你介绍了Intel是怎么在Pentium 4处理器上遭遇重大失败的。如果不太记得的话你可以回过头去回顾一下。
那时我和你说过Pentium 4失败的一个重要原因就是它的CPU的流水线级数太深了。早期的Pentium 4的流水线深度高达20级而后期的代号为Prescott的Pentium 4的流水线级数更是到了31级。超长的流水线使得之前我们讲的很多解决“冒险”、提升并发的方案都用不上。
因为这些解决“冒险”、提升并发的方案,本质上都是一种**指令级并行**Instruction-level parallelism简称IPL的技术方案。换句话说就是CPU想要在同一个时间去并行地执行两条指令。而这两条指令呢原本在我们的代码里是有先后顺序的。无论是我们在流水线里面讲到的流水线架构、分支预测以及乱序执行还是我们在上一讲说的超标量和超长指令字都是想要通过同一时间执行两条指令来提升CPU的吞吐率。
然而在Pentium 4这个CPU上这些方法都可能因为流水线太深而起不到效果。我之前讲过更深的流水线意味着同时在流水线里面的指令就多相互的依赖关系就多。于是很多时候我们不得不把流水线停顿下来插入很多NOP操作来解决这些依赖带来的“冒险”问题。
不知道是不是因为当时面临的竞争太激烈了为了让Pentium 4的CPU在性能上更有竞争力一点2002年底Intel在的3.06GHz主频的Pentium 4 CPU上第一次引入了**超线程**Hyper-Threading技术。
什么是超线程技术呢Intel想既然CPU同时运行那些在代码层面有前后依赖关系的指令会遇到各种冒险问题我们不如去找一些和这些指令完全独立没有依赖关系的指令来运行好了。那么这样的指令哪里来呢自然同时运行在另外一个程序里了。
你所用的计算机其实同一个时间可以运行很多个程序。比如我现在一边在浏览器里写这篇文章后台同样运行着一个Python脚本程序。而这两个程序是完全相互独立的。它们两个的指令完全并行运行而不会产生依赖问题带来的“冒险”。
然而这个时候你可能就会觉得奇怪了这么做似乎不需要什么新技术呀。现在我们用的CPU都是多核的本来就可以用多个不同的CPU核心去运行不同的任务。即使当时的Pentium 4是单核的我们的计算机本来也能同时运行多个进程或者多个线程。这个超线程技术有什么特别的用处呢
无论是上面说的多个CPU核心运行不同的程序还是在单个CPU核心里面切换运行不同线程的任务在同一时间点上一个物理的CPU核心只会运行一个线程的指令所以其实我们并没有真正地做到指令的并行运行。
<img src="https://static001.geekbang.org/resource/image/96/c8/96aa1220ff27776f55091c55c2eddbc8.jpeg" alt="">
超线程可不是这样。超线程的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里的各个功能单元去处理另外一个数据库连接的查询请求就是一个很好的应用案例。
<img src="https://static001.geekbang.org/resource/image/75/e5/759b55d8acdc6f69d0b711f4f62ad4e5.png" alt="">
我这里放了一张我的电脑里运行CPU-Z的截图。你可以看到在右下角里我的CPU的Cores被标明了是4而Threads则是8。这说明我手头的这个CPU只有4个物理的CPU核心也就是所谓的4核CPU。但是在逻辑层面它“装作”有8个CPU核心可以利用超线程技术来同时运行8条指令。如果你用的是Windows可以去下载安装一个[CPU-Z](https://www.cpuid.com/softwares/cpu-z.html)来看看你手头的CPU里面对应的参数。
## SIMD如何加速矩阵乘法
在上面的CPU信息的图里面你会看到中间有一组信息叫作Instructions里面写了有MMX、SSE等等。这些信息就是这个CPU所支持的指令集。这里的MMX和SSE的指令集也就引出了我要给你讲的最后一个提升CPU性能的技术方案**SIMD**,中文叫作**单指令多数据流**Single Instruction Multiple Data
我们先来体会一下SIMD的性能到底怎么样。下面是两段示例程序一段呢是通过循环的方式给一个list里面的每一个数加1。另一段呢是实现相同的功能但是直接调用NumPy这个库的add方法。在统计两段程序的性能的时候我直接调用了Python里面的timeit的库。
```
$ python
&gt;&gt;&gt; import numpy as np
&gt;&gt;&gt; import timeit
&gt;&gt;&gt; a = list(range(1000))
&gt;&gt;&gt; b = np.array(range(1000))
&gt;&gt;&gt; timeit.timeit(&quot;[i + 1 for i in a]&quot;, setup=&quot;from __main__ import a&quot;, number=1000000)
32.82800309999993
&gt;&gt;&gt; timeit.timeit(&quot;np.add(1, b)&quot;, setup=&quot;from __main__ import np, b&quot;, number=1000000)
0.9787889999997788
&gt;&gt;&gt;
```
从两段程序的输出结果来看你会发现两个功能相同的代码性能有着巨大的差异足足差出了30多倍。也难怪所有用Python讲解数据科学的教程里往往在一开始就告诉你不要使用循环而要把所有的计算都向量化Vectorize
有些同学可能会猜测是不是因为Python是一门解释性的语言所以这个性能差异会那么大。第一段程序的循环的每一次操作都需要Python解释器来执行而第二段的函数调用是一次调用编译好的原生代码所以才会那么快。如果你这么想不妨试试直接用C语言实现一下1000个元素的数组里面的每个数加1。你会发现即使是C语言编译出来的代码还是远远低于NumPy。原因就是NumPy直接用到了SIMD指令能够并行进行向量的操作。
而前面使用循环来一步一步计算的算法呢,一般被称为**SISD**,也就是**单指令单数据**Single Instruction Single 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次对应的数据时间就省下来了。
<img src="https://static001.geekbang.org/resource/image/48/a6/48ddcd5ac345091c1be5963d5ef7d7a6.jpeg" alt="">
在数据读取到了之后在指令的执行层面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的时候我们还会遇到它。
## 总结延伸
这一讲我们讲完了超线程和SIMD这两个CPU的“并行计算”方案。超线程其实是一个“线程级并行”的解决方案。它通过让一个物理CPU核心“装作”两个逻辑层面的CPU核心使得CPU可以同时运行两个不同线程的指令。虽然这样的运行仍然有着种种的限制很多场景下超线程并不一定能带来CPU的性能提升。但是Intel通过超线程让使用者有了“占到便宜”的感觉。同样的4核心的CPU在有些情况下能够发挥出8核心CPU的作用。而超线程在今天也已经成为Intel CPU的标配了。
而SIMD技术则是一种“指令级并行”的加速方案或者我们可以说它是一种“数据并行”的加速方案。在处理向量计算的情况下同一个向量的不同维度之间的计算是相互独立的。而我们的CPU里的寄存器又能放得下多条数据。于是我们可以一次性取出多条数据交给CPU并行计算。
正是SIMD技术的出现使得我们在Pentium时代的个人PC开始有了多媒体运算的能力。可以说Intel的MMX、SSE指令集和微软的Windows 95这样的图形界面操作系统推动了PC快速进入家庭的历史进程。
## 推荐阅读
如果你想看一看Intel CPU里面的SIMD指令具体长什么样可以去读一读《计算机组成与设计硬件/软件接口》的3.7章节。
## 课后思考
最后,给你留一道思考题。超线程这样的技术,在什么样的应用场景下最高效?你在自己开发系统的过程中,是否遇到超线程技术为程序带来性能提升的情况呢?
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,111 @@
<audio id="audio" title="28 | 异常和中断:程序出错了怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/cb/939f707f0c9e474f33ce053330a55ecb.mp3"></audio>
过去这么多讲,我们的程序都是自动运行且正常运行的。自动运行的意思是说,我们的程序和指令都是一条条顺序执行,你不需要通过键盘或者网络给这个程序任何输入。正常运行是说,我们的程序都是能够正常执行下去的,没有遇到计算溢出之类的程序错误。
不过现实的软件世界可没有这么简单。一方面程序不仅是简单的执行指令更多的还需要和外部的输入输出打交道。另一方面程序在执行过程中还会遇到各种异常情况比如除以0、溢出甚至我们自己也可以让程序抛出异常。
那这一讲,我就带你来看看,如果遇到这些情况,计算机是怎么运转的,也就是说,计算机究竟是如何处理异常的。
## 异常:硬件、系统和应用的组合拳
一提到计算机当中的**异常**Exception可能你的第一反应就是C++或者Java中的Exception。不过我们今天讲的并不是这些软件开发过程中遇到的“软件异常”而是和硬件、系统相关的“硬件异常”。
当然“软件异常”和“硬件异常”并不是实际业界使用的专有名词只是我为了方便给你说明和C++、Java中软件抛出的Exception进行的人为区分你明白这个意思就好。
尽管,这里我把这些硬件和系统相关的异常,叫作“硬件异常”。但是,实际上,这些异常,既有来自硬件的,也有来自软件层面的。
比如我们在硬件层面当加法器进行两个数相加的时候会遇到算术溢出或者你在玩游戏的时候按下键盘发送了一个信号给到CPUCPU要去执行一个现有流程之外的指令这也是一个“异常”。
同样,来自软件层面的,比如我们的程序进行系统调用,发起一个读文件的请求。这样应用程序向系统调用发起请求的情况,一样是通过“异常”来实现的。
**关于异常,最有意思的一点就是,它其实是一个硬件和软件组合到一起的处理过程。异常的前半生,也就是异常的发生和捕捉,是在硬件层面完成的。但是异常的后半生,也就是说,异常的处理,其实是由软件来完成的。**
计算机会为每一种可能会发生的异常分配一个异常代码Exception Number。有些教科书会把异常代码叫作中断向量Interrupt Vector。异常发生的时候通常是CPU检测到了一个特殊的信号。比如你按下键盘上的按键输入设备就会给CPU发一个信号。或者正在执行的指令发生了加法溢出同样我们可以有一个进位溢出的信号。这些信号呢在组成原理里面我们一般叫作发生了一个事件Event。CPU在检测到事件的时候其实也就拿到了对应的异常代码。
**这些异常代码里I/O发出的信号的异常代码是由操作系统来分配的也就是由软件来设定的。而像加法溢出这样的异常代码则是由CPU预先分配好的也就是由硬件来分配的。这又是另一个软件和硬件共同组合来处理异常的过程。**
拿到异常代码之后CPU就会触发异常处理的流程。计算机在内存里会保留一个异常表Exception Table。也有地方把这个表叫作中断向量表Interrupt Vector Table好和上面的中断向量对应起来。这个异常表有点儿像我们在[第10讲](https://time.geekbang.org/column/article/95244)里讲的GOT表存放的是不同的异常代码对应的异常处理程序Exception Handler所在的地址。
我们的CPU在拿到了异常码之后会先把当前的程序执行的现场保存到程序栈里面然后根据异常码查询找到对应的异常处理程序最后把后续指令执行的指挥权交给这个异常处理程序。
<img src="https://static001.geekbang.org/resource/image/e8/d6/e8a49f09d1bb50e4d42fccd14d743ad6.jpeg" alt="">
这样“检测异常,拿到异常码,再根据异常码进行查表处理”的模式,在日常开发的过程中是很常见的。
<img src="https://static001.geekbang.org/resource/image/27/f7/272b21cc50572c208b4db4b8ef8276f7.jpeg" alt="">
比如说现在我们日常进行的Web或者App开发通常都是前后端分离的。前端的应用会向后端发起HTTP的请求。当后端遇到了异常通常会给到前端一个对应的错误代码。前端的应用根据这个错误代码在应用层面去进行错误处理。在不能处理的时候它会根据错误代码向用户显示错误信息。
```
public class LastChanceHandler implements Thread.UncaughtExceptionHandler {
@Override
public void uncaughtException(Thread t, Throwable e) {
// do something here - log to file and upload to server/close resources/delete files...
}
}
Thread.setDefaultUncaughtExceptionHandler(new LastChanceHandler());
```
再比如说Java里面我们使用一个线程池去运行调度任务的时候可以指定一个异常处理程序。对于各个线程在执行任务出现的异常情况我们是通过异常处理程序进行处理而不是在实际的任务代码里处理。这样我们就把业务处理代码就和异常处理代码的流程分开了。
## 异常的分类:中断、陷阱、故障和中止
我在前面说了,异常可以由硬件触发,也可以由软件触发。那我们平时会碰到哪些异常呢?下面我们就一起来看看。
第一种异常叫**中断**Interrupt。顾名思义自然就是程序在执行到一半的时候被打断了。这个打断执行的信号来自于CPU外部的I/O设备。你在键盘上按下一个按键就会对应触发一个相应的信号到达CPU里面。CPU里面某个开关的值发生了变化也就触发了一个中断类型的异常。
第二种异常叫**陷阱**Trap。陷阱其实是我们程序员“故意“主动触发的异常。就好像你在程序里面打了一个断点这个断点就是设下的一个"陷阱"。当程序的指令执行到这个位置的时候,就掉到了这个陷阱当中。然后,对应的异常处理程序就会来处理这个"陷阱"当中的猎物。
最常见的一类陷阱,发生在我们的应用程序调用系统调用的时候,也就是从程序的用户态切换到内核态的时候。我们在[第3讲](https://time.geekbang.org/column/article/92215)讲CPU性能的时候说过可以用Linux下的time指令去查看一个程序运行实际花费的时间里面有在用户态花费的时间user time也有在内核态发生的时间system time
我们的应用程序通过系统调用去读取文件、创建进程,其实也是通过触发一次陷阱来进行的。这是因为,我们用户态的应用程序没有权限来做这些事情,需要把对应的流程转交给有权限的异常处理程序来进行。
第三种异常叫**故障**Fault。它和陷阱的区别在于陷阱是我们开发程序的时候刻意触发的异常而故障通常不是。比如我们在程序执行的过程中进行加法计算发生了溢出其实就是故障类型的异常。这个异常不是我们在开发的时候计划内的也一样需要有对应的异常处理程序去处理。
故障和陷阱、中断的一个重要区别是,故障在异常程序处理完成之后,仍然回来处理当前的指令,而不是去执行程序中的下一条指令。因为当前的指令因为故障的原因并没有成功执行完成。
最后一种异常叫**中止**Abort。与其说这是一种异常类型不如说这是故障的一种特殊情况。当CPU遇到了故障但是恢复不过来的时候程序就不得不中止了。
<img src="https://static001.geekbang.org/resource/image/da/a8/da0117e669ebd2bd06c19beaf12d0da8.jpeg" alt="">
在这四种异常里,中断异常的信号来自系统外部,而不是在程序自己执行的过程中,所以我们称之为“异步”类型的异常。而陷阱、故障以及中止类型的异常,是在程序执行的过程中发生的,所以我们称之为“同步“类型的异常。
在处理异常的过程当中,无论是异步的中断,还是同步的陷阱和故障,我们都是采用同一套处理流程,也就是上面所说的,“保存现场、异常代码查询、异常处理程序调用“。而中止类型的异常,其实是在故障类型异常的一种特殊情况。当故障发生,但是我们发现没有异常处理程序能够处理这种异常的情况下,程序就不得不进入中止状态,也就是最终会退出当前的程序执行。
## 异常的处理:上下文切换
在实际的异常处理程序执行之前CPU需要去做一次“保存现场”的操作。这个保存现场的操作和我在[第7讲](https://time.geekbang.org/column/article/94427)里讲解函数调用的过程非常相似。
因为切换到异常处理程序的时候,其实就好像是去调用一个异常处理函数。指令的控制权被切换到了另外一个"函数"里面,所以我们自然要把当前正在执行的指令去压栈。这样,我们才能在异常处理程序执行完成之后,重新回到当前的指令继续往下执行。
不过,切换到异常处理程序,比起函数调用,还是要更复杂一些。原因有下面几点。
第一点因为异常情况往往发生在程序正常执行的预期之外比如中断、故障发生的时候。所以除了本来程序压栈要做的事情之外我们还需要把CPU内当前运行程序用到的所有寄存器都放到栈里面。最典型的就是条件码寄存器里面的内容。
第二点,像陷阱这样的异常,涉及程序指令在用户态和内核态之间的切换。对应压栈的时候,对应的数据是压到内核栈里,而不是程序栈里。
第三点,像故障这样的异常,在异常处理程序执行完成之后。从栈里返回出来,继续执行的不是顺序的下一条指令,而是故障发生的当前指令。因为当前指令因为故障没有正常执行成功,必须重新去执行一次。
所以对于异常这样的处理流程不像是顺序执行的指令间的函数调用关系。而是更像两个不同的独立进程之间在CPU层面的切换所以这个过程我们称之为**上下文切换**Context Switch
## 总结延伸
这一讲我给你讲了计算机里的“异常”处理流程。这里的异常可以分成中断、陷阱、故障、中止这样四种情况。这四种异常分别对应着I/O设备的输入、程序主动触发的状态切换、异常情况下的程序出错以及出错之后无可挽回的退出程序。
当CPU遭遇了异常的时候计算机就需要有相应的应对措施。CPU会通过“查表法”来解决这个问题。在硬件层面和操作系统层面各自定义了所有CPU可能会遇到的异常代码并且通过这个异常代码在异常表里面查询相应的异常处理程序。在捕捉异常的时候我们的硬件CPU在进行相应的操作而在处理异常层面则是由作为软件的异常处理程序进行相应的操作。
而在实际处理异常之前计算机需要先去做一个“保留现场”的操作。有了这个操作我们才能在异常处理完成之后重新回到之前执行的指令序列里面来。这个保留现场的操作和我们之前讲解指令的函数调用很像。但是因为“异常”和函数调用有一个很大的不同那就是它的发生时间。函数调用的压栈操作我们在写程序的时候完全能够知道而“异常”发生的时间却很不确定。所以“异常”发生的时候我们称之为发生了一次“上下文切换”Context Switch。这个时候除了普通需要压栈的数据外计算机还需要把所有寄存器信息都存储到栈里面去。
## 推荐阅读
关于异常和中断《深入理解计算机系统》的第8章“异常控制流”部分有非常深入和充分的讲解推荐你认真阅读一下。
## 课后思考
很多教科书和网上的文章,会把中断分成软中断和硬中断。你能用自己的话说一说,什么是软中断,什么是硬中断吗?它们和我们今天说的中断、陷阱、故障以及中止又有什么关系呢?
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,116 @@
<audio id="audio" title="29 | CISC和RISC为什么手机芯片都是ARM" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/ba/d2cd1dacdd605eacafb15c91ea84a6ba.mp3"></audio>
我在[第5讲](https://time.geekbang.org/column/article/93359)讲计算机指令的时候给你看过MIPS体系结构计算机的机器指令格式。MIPS的指令都是固定的32位长度如果要用一个打孔卡来表示并不复杂。
<img src="https://static001.geekbang.org/resource/image/b1/bf/b1ade5f8de67b172bf7b4ec9f63589bf.jpeg" alt="">
[第6讲](https://time.geekbang.org/column/article/94075)的时候我带你编译了一些简单的C语言程序看了x86体系结构下的汇编代码。眼尖的话你应该能发现每一条机器码的长度是不一样的。
<img src="https://static001.geekbang.org/resource/image/ad/8a/ad91b005e97959d571bbd2a0fa30b48a.jpeg" alt="">
而CPU的指令集里的机器码是固定长度还是可变长度也就是**复杂指令集**Complex Instruction Set Computing简称CISC和**精简指令集**Reduced Instruction Set Computing简称RISC这两种风格的指令集一个最重要的差别。那今天我们就来看复杂指令集和精简指令集之间的对比、差异以及历史纠葛。
## CISC VS RISC历史的车轮不总是向前的
在计算机历史的早期其实没有什么CISC和RISC之分。或者说所有的CPU其实都是CISC。
虽然冯·诺依曼高屋建瓴地提出了存储程序型计算机的基础架构但是实际的计算机设计和制造还是严格受硬件层面的限制。当时的计算机很慢存储空间也很小。《人月神话》这本软件工程界的名著讲的是花了好几年设计IBM 360这台计算机的经验。IBM 360的最低配置每秒只能运行34500条指令只有8K的内存。为了让计算机能够做尽量多的工作每一个字节乃至每一个比特都特别重要。
所以CPU指令集的设计需要仔细考虑硬件限制。为了性能考虑很多功能都直接通过硬件电路来完成。为了少用内存指令的长度也是可变的。就像算法和数据结构里的[赫夫曼编码Huffman coding](https://en.wikipedia.org/wiki/Huffman_coding)一样,常用的指令要短一些,不常用的指令可以长一些。那个时候的计算机,想要用尽可能少的内存空间,存储尽量多的指令。
不过历史的车轮滚滚向前计算机的性能越来越好存储的空间也越来越大了。到了70年代末RISC开始登上了历史的舞台。当时[UC Berkeley](https://en.wikipedia.org/wiki/University_of_California,_Berkeley)的大卫·帕特森David Patterson教授发现实际在CPU运行的程序里80%的时间都是在使用20%的简单指令。于是他就提出了RISC的理念。自此之后RISC类型的CPU开始快速蓬勃发展。
我经常推荐的课后阅读材料,有不少是来自《计算机组成与设计:硬件/软件接口》和《计算机体系结构量化研究方法》这两本教科书。大卫·帕特森教授正是这两本书的作者。此外他还在2017年获得了图灵奖。
<img src="https://static001.geekbang.org/resource/image/d6/77/d69a1e753fa1523df054573f13516277.jpeg" alt="">
RISC架构的CPU究竟是什么样的呢为什么它能在这么短的时间内受到如此大的追捧
RISC架构的CPU的想法其实非常直观。既然我们80%的时间都在用20%的简单指令那我们能不能只要那20%的简单指令就好了呢?答案当然是可以的。因为指令数量多,计算机科学家们在软硬件两方面都受到了很多挑战。
在硬件层面我们要想支持更多的复杂指令CPU里面的电路就要更复杂设计起来也就更困难。更复杂的电路在散热和功耗层面也会带来更大的挑战。在软件层面支持更多的复杂指令编译器的优化就变得更困难。毕竟面向2000个指令来优化编译器和面向500个指令来优化编译器的困难是完全不同的。
于是在RISC架构里面CPU选择把指令“精简”到20%的简单指令。而原先的复杂指令则通过用简单指令组合起来来实现让软件来实现硬件的功能。这样CPU的整个硬件设计就会变得更简单了在硬件层面提升性能也会变得更容易了。
RISC的CPU里完成指令的电路变得简单了于是也就腾出了更多的空间。这个空间常常被拿来放通用寄存器。因为RISC完成同样的功能执行的指令数量要比CISC多所以如果需要反复从内存里面读取指令或者数据到寄存器里来那么很多时间就会花在访问内存上。于是RISC架构的CPU往往就有更多的通用寄存器。
除了寄存器这样的存储空间RISC的CPU也可以把更多的晶体管用来实现更好的分支预测等相关功能进一步去提升CPU实际的执行效率。
总的来说对于CISC和RISC的对比我们可以一起回到第4讲讲的程序运行时间的公式
CISC的架构其实就是通过优化**指令数**来减少CPU的执行时间。而RISC的架构其实是在优化CPI。因为指令比较简单需要的时钟周期就比较少。
因为RISC降低了CPU硬件的设计和开发难度所以从80年代开始大部分新的CPU都开始采用RISC架构。从IBM的PowerPC到SUN的SPARC都是RISC架构。所有人看到仍然采用CISC架构的Intel CPU都可以批评一句“Complex and messy”。但是为什么无论是在PC上还是服务器上仍然是Intel成为最后的赢家呢
## Intel的进化微指令架构的出现
面对这么多负面评价的Intel自然也不能无动于衷。更何况x86架构的问题并不能说明Intel的工程师不够厉害。事实上在整个CPU设计的领域Intel集中了大量优秀的人才。无论是成功的Pentium时代引入的超标量设计还是失败的Pentium 4时代引入的超线程技术都是异常精巧的工程实现。
而x86架构所面临的种种问题其实都来自于一个最重要的考量那就是指令集的向前兼容性。因为x86在商业上太成功了所以市场上有大量的Intel CPU。而围绕着这些CPU又有大量的操作系统、编译器。这些系统软件只支持x86的指令集就比如著名的Windows 95。而在这些系统软件上又有各种各样的应用软件。
如果Intel要放弃x86的架构和指令集开发一个RISC架构的CPU面临的第一个问题就是所有这些软件都是不兼容的。事实上Intel并非没有尝试过在x86之外另起炉灶这其实就是我在[](https://time.geekbang.org/column/article/102888)[26讲](https://time.geekbang.org/column/article/102888)介绍的安腾处理器。当时Intel想要在CPU进入64位的时代的时候丢掉x86的历史包袱所以推出了全新的IA-64的架构。但是却因为不兼容x86的指令集遭遇了重大的失败。
反而是AMD趁着Intel研发安腾的时候推出了兼容32位x86指令集的64位架构也就是AMD64。如果你现在在Linux下安装各种软件包一定经常会看到像下面这样带有AMD64字样的内容。这是因为x86下的64位的指令集x86-64并不是Intel发明的而是AMD发明的。
```
Get:1 http://archive.ubuntu.com/ubuntu bionic/main amd64 fontconfig amd64 2.12.6-0ubuntu2 [169 kB]
```
花开两朵各表一枝。Intel在开发安腾处理器的同时也在不断借鉴其他RISC处理器的设计思想。既然核心问题是要始终向前兼容x86的指令集那么我们能不能不修改指令集但是让CISC风格的指令集用RISC的形式在CPU里面运行呢
于是从Pentium Pro时代开始Intel就开始在处理器里引入了**微指令**Micro-Instructions/Micro-Ops**架构**。而微指令架构的引入也让CISC和RISC的分界变得模糊了。
<img src="https://static001.geekbang.org/resource/image/3c/76/3c4ceec254e765462b09f393153f4476.jpeg" alt="">
在微指令架构的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里面拿到译码结果而不需要让译码器去进行实际的译码操作。这样不仅优化了性能因为译码器的晶体管开关动作变少了还减少了功耗。
因为“微指令”架构的存在从Pentium Pro开始Intel处理器已经不是一个纯粹的CISC处理器了。它同样融合了大量RISC类型的处理器设计。不过由于Intel本身在CPU层面做的大量优化比如乱序执行、分支预测等相关工作x86的CPU始终在功耗上还是要远远超过RISC架构的ARM所以最终在智能手机崛起替代PC的时代落在了ARM后面。
## ARM和RISC-VCPU的现在与未来
2017年ARM公司的CEO Simon Segards宣布ARM累积销售的芯片数量超过了1000亿。作为一个从12个人起步在80年代想要获取Intel的80286架构授权来制造CPU的公司ARM是如何在移动端把自己的芯片塑造成了最终的霸主呢
ARM这个名字现在的含义是“Advanced RISC Machines”。你从名字就能够看出来ARM的芯片是基于RISC架构的。不过ARM能够在移动端战胜Intel并不是因为RISC架构。
到了21世纪的今天CISC和RISC架构的分界已经没有那么明显了。Intel和AMD的CPU也都是采用译码成RISC风格的微指令来运行。而ARM的芯片一条指令同样需要多个时钟周期有乱序执行和多发射。我甚至看到过这样的评价“ARM和RISC的关系只有在名字上”。
ARM真正能够战胜Intel我觉得主要是因为下面这两点原因。
第一点是功耗优先的设计。一个4核的Intel i7的CPU设计的时候功率就是130W。而一块ARM A8的单个核心的CPU设计功率只有2W。两者之间差出了100倍。在移动设备上功耗是一个远比性能更重要的指标毕竟我们不能随时在身上带个发电机。ARM的CPU主频更低晶体管更少高速缓存更小乱序执行的能力更弱。所有这些都是为了功耗所做的妥协。
第二点则是低价。ARM并没有自己垄断CPU的生产和制造只是进行CPU设计然后把对应的知识产权授权出去让其他的厂商来生产ARM架构的CPU。它甚至还允许这些厂商可以基于ARM的架构和指令集设计属于自己的CPU。像苹果、三星、华为它们都是拿到了基于ARM体系架构设计和制造CPU的授权。ARM自己只是收取对应的专利授权费用。多个厂商之间的竞争使得ARM的芯片在市场上价格很便宜。所以尽管ARM的芯片的出货量远大于Intel但是收入和利润却比不上Intel。
不过ARM并不是开源的。所以在ARM架构逐渐垄断移动端芯片市场的时候“开源硬件”也慢慢发展起来了。一方面MIPS在2019年宣布开源另一方面从UC Berkeley发起的[RISC-V](https://riscv.org/)项目也越来越受到大家的关注。而RISC概念的发明人图灵奖的得主大卫·帕特森教授从伯克利退休之后成了RISC-V国际开源实验室的负责人开始推动RISC-V这个“CPU届的Linux”的开发。可以想见未来的开源CPU也多半会像Linux一样逐渐成为一个业界的主流选择。如果想要“打造一个属于自己CPU”不可不关注这个项目。
## 总结延伸
这一讲我从RISC和CISC架构之前的差异说起讲到RISC的指令是固定长度的CISC的指令是可变长度的。RISC的指令集里的指令数少而且单个指令只完成简单的功能所以被称为“精简”。CISC里的指令数多为了节约内存直接在硬件层面能够完成复杂的功能所以被称为“复杂”。RISC的通过减少CPI来提升性能而CISC通过减少需要的指令数来提升性能。
然后我们进一步介绍了Intel的x86 CPU的“微指令”的设计思路。“微指令”使得我们在机器码层面保留了CISC风格的x86架构的指令集。但是通过指令译码器和L0缓存的组合使得这些指令可以快速翻译成RISC风格的微指令使得实际执行指令的流水线可以用RISC的架构来搭建。使用“微指令”设计思路的CPU不能再称之为CISC了而更像一个RISC和CISC融合的产物。
过去十年里Intel仍然把持着PC和服务器市场但是更多的市场上的CPU芯片来自基于ARM架构的智能手机了。而在ARM似乎已经垄断了移动CPU市场的时候开源的RISC-V出现了也给了计算机工程师们新的设计属于自己的CPU的机会。
## 推荐阅读
又到了推荐阅读的时间了,这次我们又要一起来读论文了。
想要了解x86和ARM之间的功耗和性能的差异以及这个差异到底从哪里来你可以读一读[《Power Struggles: Revisiting the RISC vs. CISC Debate on Contemporary ARM and x86 Architectures》](https://research.cs.wisc.edu/vertical/papers/2013/hpca13-isa-power-struggles.pdf)这篇论文。
这个12页的论文仔细研究了Intel和ARM的差异并且得出了一个结论。那就是ARM和x86之间的功耗差异并不是来自于CISC和RISC的指令集差异而是因为两类芯片的设计本就是针对不同的性能目标而进行的和指令集是CISC还是RISC并没有什么关系。
## 课后思考
Intel除了x86和安腾之外还推出过Atom这个面向移动设备的低功耗CPU。那Atom究竟是RISC还是CISC架构的CPU呢
你可以搜索一下相关资料,在留言区写下你搜索到的内容。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,107 @@
<audio id="audio" title="30 | GPU为什么玩游戏需要使用GPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/3c/0377d8ec851a248aacd498beba66c53c.mp3"></audio>
讲完了CPU我带你一起来看一看计算机里的另外一个处理器也就是被称之为GPU的图形处理器。过去几年里因为深度学习的大发展GPU一下子火起来了似乎GPU成了一个专为深度学习而设计的处理器。那GPU的架构究竟是怎么回事儿呢它最早是用来做什么而被设计出来的呢
想要理解GPU的设计我们就要从GPU的老本行图形处理说起。因为图形处理才是GPU设计用来做的事情。只有了解了图形处理的流程我们才能搞明白为什么GPU要设计成现在这样为什么在深度学习上GPU比起CPU有那么大的优势。
## GPU的历史进程
GPU是随着我们开始在计算机里面需要渲染三维图形的出现而发展起来的设备。图形渲染和设备的先驱第一个要算是SGISilicon Graphics Inc.这家公司。SGI的名字翻译成中文就是“硅谷图形公司”。这家公司从80年代起就开发了很多基于Unix操作系统的工作站。它的创始人Jim Clark是斯坦福的教授也是图形学的专家。
后来他也是网景公司Netscape的创始人之一。而Netscape就是那个曾经和IE大战300回合的浏览器公司虽然最终败在微软的Windows免费捆绑IE的策略下但是也留下了Firefox这个完全由开源基金会管理的浏览器。不过这个都是后话了。
到了90年代中期随着个人电脑的性能越来越好PC游戏玩家们开始有了“3D显卡”的需求。那个时代之前的3D游戏其实都是伪3D。比如大神卡马克开发的著名[Wolfenstein 3D](https://en.wikipedia.org/wiki/Wolfenstein_3D)德军总部3D从不同视角看到的是8幅不同的贴图实际上并不是通过图形学绘制渲染出来的多边形。
这样的情况下游戏玩家的视角旋转个10度看到的画面并没有变化。但是如果转了45度看到的画面就变成了另外一幅图片。而如果我们能实时渲染基于多边形的3D画面的话那么任何一点点的视角变化都会实时在画面里面体现出来就好像你在真实世界里面看到的一样。
而在90年代中期随着硬件和技术的进步我们终于可以在PC上用硬件直接实时渲染多边形了。“真3D”游戏开始登上历史舞台了。“古墓丽影”“最终幻想7”这些游戏都是在那个时代诞生的。当时很多国内的计算机爱好者梦寐以求的是一块Voodoo FX的显卡。
那为什么CPU的性能已经大幅度提升了但是我们还需要单独的GPU呢想要了解这个问题我们先来看一看三维图像实际通过计算机渲染出来的流程。
## 图形渲染的流程
现在我们电脑里面显示出来的3D的画面其实是通过多边形组合出来的。你可以看看下面这张图你在玩的各种游戏里面的人物的脸并不是那个相机或者摄像头拍出来的而是通过[多边形建模](https://en.wikipedia.org/wiki/Polygonal_modeling)Polygon Modeling创建出来的。
<img src="https://static001.geekbang.org/resource/image/07/22/0777aed6775051cfd83d0bb512de8722.png" alt="">
而实际这些人物在画面里面的移动、动作,乃至根据光线发生的变化,都是通过计算机根据图形学的各种计算,实时渲染出来的。
这个对于图像进行实时渲染的过程可以被分解成下面这样5个步骤
1. 顶点处理Vertex Processing
1. 图元处理Primitive Processing
1. 栅格化Rasterization
1. 片段处理Fragment Processing
1. 像素操作Pixel Operations
我们现在来一步一步看这5个步骤。
### 顶点处理
图形渲染的第一步是顶点处理。构成多边形建模的每一个多边形呢都有多个顶点Vertex。这些顶点都有一个在三维空间里的坐标。但是我们的屏幕是二维的所以在确定当前视角的时候我们需要把这些顶点在三维空间里面的位置转化到屏幕这个二维空间里面。这个转换的操作就被叫作顶点处理。
如果你稍微学过一点图形学的话,应该知道,这样的转化都是通过线性代数的计算来进行的。可以想见,我们的建模越精细,需要转换的顶点数量就越多,计算量就越大。**而且,这里面每一个顶点位置的转换,互相之间没有依赖,是可以并行独立计算的。**
<img src="https://static001.geekbang.org/resource/image/04/de/04c3da62c382e45b8f891cfa046169de.jpeg" alt="">
### 图元处理
在顶点处理完成之后呢我们需要开始进行第二步也就是图元处理。图元处理其实就是要把顶点处理完成之后的各个顶点连起来变成多边形。其实转化后的顶点仍然是在一个三维空间里只是第三维的Z轴是正对屏幕的“深度”。所以我们针对这些多边形需要做一个操作叫剔除和裁剪Cull and Clip也就是把不在屏幕里面或者一部分不在屏幕里面的内容给去掉减少接下来流程的工作量。
<img src="https://static001.geekbang.org/resource/image/4a/1d/4a20559c43f93177d7a99081a0cd0e1d.jpeg" alt="">
### 栅格化
在图元处理完成之后呢渲染还远远没有完成。我们的屏幕分辨率是有限的。它一般是通过一个个“像素Pixel”来显示出内容的。所以对于做完图元处理的多边形我们要开始进行第三步操作。这个操作就是把它们转换成屏幕里面的一个个像素点。这个操作呢就叫作栅格化。**这个栅格化操作,有一个特点和上面的顶点处理是一样的,就是每一个图元都可以并行独立地栅格化。**
<img src="https://static001.geekbang.org/resource/image/e6/a6/e60a58e632fc05dbf96eaa5cbb7fb2a6.jpeg" alt="">
### 片段处理
在栅格化变成了像素点之后,我们的图还是“黑白”的。我们还需要计算每一个像素的颜色、透明度等信息,给像素点上色。这步操作,就是片段处理。**这步操作,同样也可以每个片段并行、独立进行,和上面的顶点处理和栅格化一样。**
<img src="https://static001.geekbang.org/resource/image/49/08/490f298719e81beb1871c10566d56308.jpeg" alt="">
### 像素操作
最后一步呢我们就要把不同的多边形的像素点“混合Blending”到一起。可能前面的多边形可能是半透明的那么前后的颜色就要混合在一起变成一个新的颜色或者前面的多边形遮挡住了后面的多边形那么我们只要显示前面多边形的颜色就好了。最终输出到显示设备。
<img src="https://static001.geekbang.org/resource/image/31/1f/312b8e4730ac04d36c99ee7c56bbba1f.jpg" alt="">
经过这完整的5个步骤之后我们就完成了从三维空间里的数据的渲染变成屏幕上你可以看到的3D动画了。这样5个步骤的渲染流程呢一般也被称之为**图形流水线**Graphic Pipeline。这个名字和我们讲解CPU里面的流水线非常相似都叫**Pipeline**。
<img src="https://static001.geekbang.org/resource/image/bf/8f/bf6554dffdf501182ac45bc59d30648f.jpeg" alt="">
## 解放图形渲染的GPU
我们可以想一想如果用CPU来进行这个渲染过程需要花上多少资源呢我们可以通过一些数据来做个粗略的估算。
在上世纪90年代的时候屏幕的分辨率还没有现在那么高。一般的CRT显示器也就是640×480的分辨率。这意味着屏幕上有30万个像素需要渲染。为了让我们的眼睛看到画面不晕眩我们希望画面能有60帧。于是每秒我们就要重新渲染60次这个画面。也就是说每秒我们需要完成1800万次单个像素的渲染。从栅格化开始每个像素有3个流水线步骤即使每次步骤只有1个指令那我们也需要5400万条指令也就是54M条指令。
90年代的CPU的性能是多少呢93年出货的第一代Pentium处理器主频是60MHz后续逐步推出了66MHz、75MHz、100MHz的处理器。以这个性能来看用CPU来渲染3D图形基本上就要把CPU的性能用完了。因为实际的每一个渲染步骤可能不止一个指令我们的CPU可能根本就跑不动这样的三维图形渲染。
也就是在这个时候Voodoo FX这样的图形加速卡登上了历史舞台。既然图形渲染的流程是固定的那我们直接用硬件来处理这部分过程不用CPU来计算是不是就好了很显然这样的硬件会比制造有同样计算性能的CPU要便宜得多。因为整个计算流程是完全固定的不需要流水线停顿、乱序执行等等的各类导致CPU计算变得复杂的问题。我们也不需要有什么可编程能力只要让硬件按照写好的逻辑进行运算就好了。
那个时候整个顶点处理的过程还是都由CPU进行的不过后续所有到图元和像素级别的处理都是通过Voodoo FX或者TNT这样的显卡去处理的。也就是从这个时代开始我们能玩上“真3D”的游戏了。
<img src="https://static001.geekbang.org/resource/image/85/db/852288ae6b69b7e649c81f90c9fd7cdb.jpeg" alt="">
不过无论是Voodoo FX还是NVidia TNT。整个显卡的架构还不同于我们现代的显卡也没有现代显卡去进行各种加速深度学习的能力。这个能力要到NVidia提出Unified Shader Archicture才开始具备。这也是我们下一讲要讲的内容。
## 总结延伸
这一讲里我带你了解了一个基于多边形建模的三维图形的渲染过程。这个渲染过程需要经过顶点处理、图元处理、栅格化、片段处理以及像素操作这5个步骤。这5个步骤把存储在内存里面的多边形数据变成了渲染在屏幕上的画面。因为里面的很多步骤都需要渲染整个画面里面的每一个像素所以其实计算量是很大的。我们的CPU这个时候就有点跑不动了。
于是像3dfx和NVidia这样的厂商就推出了3D加速卡用硬件来完成图元处理开始的渲染流程。这些加速卡和现代的显卡还不太一样它们是用固定的处理流程来完成整个3D图形渲染的过程。不过因为不用像CPU那样考虑计算和处理能力的通用性。我们就可以用比起CPU芯片更低的成本更好地完成3D图形的渲染工作。而3D游戏的时代也是从这个时候开始的。
## 推荐阅读
想要了解GPU的设计构造一个有效的办法就是回头去看看GPU的历史。我建议你好好读一读Wikipedia里面关于GPU的条目。另外也可以看看Techspot上的[The History of the Mordern Graphics Processor](https://www.techspot.com/article/650-history-of-the-gpu/)的系列文章。
## 课后思考
我们上面说的图形加速卡可以加速3D图形的渲染。那么这些显卡对于传统的2D图形也能够进行加速让CPU摆脱这些负担吗
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,121 @@
<audio id="audio" title="31 | GPU为什么深度学习需要使用GPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/7d/6344f07eca4814953ae3a9d8866dd47d.mp3"></audio>
上一讲,我带你一起看了三维图形在计算机里的渲染过程。这个渲染过程,分成了顶点处理、图元处理、 栅格化、片段处理,以及最后的像素操作。这一连串的过程,也被称之为图形流水线或者渲染管线。
因为要实时计算渲染的像素特别地多图形加速卡登上了历史的舞台。通过3dFx的Voodoo或者NVidia的TNT这样的图形加速卡CPU就不需要再去处理一个个像素点的图元处理、栅格化和片段处理这些操作。而3D游戏也是从这个时代发展起来的。
你可以看这张图这是“古墓丽影”游戏的多边形建模的变化。这个变化则是从1996年到2016年这20年来显卡的进步带来的。
<img src="https://static001.geekbang.org/resource/image/1d/c3/1d098ce5b2c779392c8d3a33636673c3.png" alt="">
## Shader的诞生和可编程图形处理器
不知道你有没有发现在Voodoo和TNT显卡的渲染管线里面没有“顶点处理“这个步骤。在当时把多边形的顶点进行线性变化转化到我们的屏幕的坐标系的工作还是由CPU完成的。所以CPU的性能越好能够支持的多边形也就越多对应的多边形建模的效果自然也就越像真人。而3D游戏的多边形性能也受限于我们CPU的性能。无论你的显卡有多快如果CPU不行3D画面一样还是不行。
所以1999年NVidia推出的GeForce 256显卡就把顶点处理的计算能力也从CPU里挪到了显卡里。不过这对于想要做好3D游戏的程序员们还不够即使到了GeForce 256。整个图形渲染过程都是在硬件里面固定的管线来完成的。程序员们在加速卡上能做的事情呢只有改配置来实现不同的图形渲染效果。如果通过改配置做不到我们就没有什么办法了。
这个时候程序员希望我们的GPU也能有一定的可编程能力。这个编程能力不是像CPU那样有非常通用的指令可以进行任何你希望的操作而是在整个的**渲染管线**Graphics Pipeline的一些特别步骤能够自己去定义处理数据的算法或者操作。于是从2001年的Direct3D 8.0开始,微软第一次引入了**可编程管线**Programable Function Pipeline的概念。
<img src="https://static001.geekbang.org/resource/image/27/6d/2724f76ffa4222eae01521cd2dffd16d.jpeg" alt="">
一开始的可编程管线呢仅限于顶点处理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就应运而生了。
既然大家用的指令集是一样的那不如就在GPU里面放很多个一样的Shader硬件电路然后通过统一调度把顶点处理、图元处理、片段处理这些任务都交给这些Shader去处理让整个GPU尽可能地忙起来。这样的设计就是我们现代GPU的设计就是统一着色器架构。
有意思的是这样的GPU并不是先在PC里面出现的而是来自于一台游戏机就是微软的XBox 360。后来这个架构才被用到ATI和NVidia的显卡里。这个时候的“着色器”的作用其实已经和它的名字关系不大了而是变成了一个通用的抽象计算模块的名字。
正是因为Shader变成一个“通用”的模块才有了把GPU拿来做各种通用计算的用法也就是**GPGPU**General-Purpose Computing on Graphics Processing Units通用图形处理器。而正是因为GPU可以拿来做各种通用的计算才有了过去10年深度学习的火热。
<img src="https://static001.geekbang.org/resource/image/da/93/dab4ed01f50995d82e6e5d970b54c693.jpeg" alt="">
## 现代GPU的三个核心创意
讲完了现代GPU的进化史那么接下来我们就来看看为什么现代的GPU在图形渲染、深度学习上能那么快。
### 芯片瘦身
我们先来回顾一下之前花了很多讲仔细讲解的现代CPU。现代CPU里的晶体管变得越来越多越来越复杂其实已经不是用来实现“计算”这个核心功能而是拿来实现处理乱序执行、进行分支预测以及我们之后要在存储器讲的高速缓存部分。
而在GPU里这些电路就显得有点多余了GPU的整个处理过程是一个[流式处理](https://en.wikipedia.org/wiki/Stream_processing)Stream Processing的过程。因为没有那么多分支条件或者复杂的依赖关系我们可以把GPU里这些对应的电路都可以去掉做一次小小的瘦身只留下取指令、指令译码、ALU以及执行这些计算需要的寄存器和缓存就好了。一般来说我们会把这些电路抽象成三个部分就是下面图里的取指令和指令译码、ALU和执行上下文。
<img src="https://static001.geekbang.org/resource/image/4c/9d/4c153ac45915fbf3985d24b092894b9d.jpeg" alt="">
### 多核并行和SIMT
这样一来我们的GPU电路就比CPU简单很多了。于是我们就可以在一个GPU里面塞很多个这样并行的GPU电路来实现计算就好像CPU里面的多核CPU一样。和CPU不同的是我们不需要单独去实现什么多线程的计算。因为GPU的运算是天然并行的。
<img src="https://static001.geekbang.org/resource/image/3d/ac/3d0859652adf9e3c0305e8e8517b47ac.jpeg" alt="">
我们在上一讲里面其实已经看到无论是对多边形里的顶点进行处理还是屏幕里面的每一个像素进行处理每个点的计算都是独立的。所以简单地添加多核的GPU就能做到并行加速。不过光这样加速还是不够工程师们觉得性能还有进一步被压榨的空间。
我们在[第27讲](https://time.geekbang.org/column/article/103433)里面讲过CPU里有一种叫作SIMD的处理技术。这个技术是说在做向量计算的时候我们要执行的指令是一样的只是同一个指令的数据有所不同而已。在GPU的渲染管线里这个技术可就大有用处了。
无论是顶点去进行线性变换还是屏幕上临近像素点的光照和上色都是在用相同的指令流程进行计算。所以GPU就借鉴了CPU里面的SIMD用了一种叫作[SIMT](https://en.wikipedia.org/wiki/Single_instruction,_multiple_threads)Single InstructionMultiple Threads的技术。SIMT呢比SIMD更加灵活。在SIMD里面CPU一次性取出了固定长度的多个数据放到寄存器里面用一个指令去执行。而SIMT可以把多条数据交给不同的线程去处理。
各个线程里面执行的指令流程是一样的但是可能根据数据的不同走到不同的条件分支。这样相同的代码和相同的流程可能执行不同的具体的指令。这个线程走到的是if的条件分支另外一个线程走到的就是else的条件分支了。
于是我们的GPU设计就可以进一步进化也就是在取指令和指令译码的阶段取出的指令可以给到后面多个不同的ALU并行进行运算。这样我们的一个GPU的核里就可以放下更多的ALU同时进行更多的并行运算了。
<img src="https://static001.geekbang.org/resource/image/3d/28/3d7ce9c053815f6a32a6fbf6f7fb9628.jpeg" alt="">
### GPU里的“超线程”
虽然GPU里面的主要以数值计算为主。不过既然已经是一个“通用计算”的架构了GPU里面也避免不了会有if…else这样的条件分支。但是在GPU里我们可没有CPU这样的分支预测的电路。这些电路在上面“芯片瘦身”的时候就已经被我们砍掉了。
所以GPU里的指令可能会遇到和CPU类似的“流水线停顿”问题。想到流水线停顿你应该就能记起我们之前在CPU里面讲过超线程技术。在GPU上我们一样可以做类似的事情也就是遇到停顿的时候调度一些别的计算任务给当前的ALU。
和超线程一样,既然要调度一个不同的任务过来,我们就需要针对这个任务,提供更多的**执行上下文**。所以一个Core里面的**执行上下文**的数量需要比ALU多。
<img src="https://static001.geekbang.org/resource/image/c9/b8/c971c34e0456dea9e4a87857880bb5b8.jpeg" alt="">
## GPU在深度学习上的性能差异
在通过芯片瘦身、SIMT以及更多的执行上下文我们就有了一个更擅长并行进行暴力运算的GPU。这样的芯片也正适合我们今天的深度学习的使用场景。
一方面GPU是一个可以进行“通用计算”的框架我们可以通过编程在GPU上实现不同的算法。另一方面现在的深度学习计算都是超大的向量和矩阵海量的训练样本的计算。整个计算过程中没有复杂的逻辑和分支非常适合GPU这样并行、计算能力强的架构。
我们去看NVidia 2080显卡的[技术规格](https://www.techpowerup.com/gpu-specs/geforce-rtx-2080.c3224),就可以算出,它到底有多大的计算能力。
2080一共有46个SMStreaming Multiprocessor流式处理器这个SM相当于GPU里面的GPU Core所以你可以认为这是一个46核的GPU有46个取指令指令译码的渲染管线。每个SM里面有64个Cuda Core。你可以认为这里的Cuda Core就是我们上面说的ALU的数量或者Pixel Shader的数量46x64呢一共就有2944个Shader。然后还有184个TMUTMU就是Texture Mapping Unit也就是用来做纹理映射的计算单元它也可以认为是另一种类型的Shader。
<img src="https://static001.geekbang.org/resource/image/14/e2/14d05a43f559cecff2b0813e8d5bdde2.png" alt="">
2080的主频是1515MHz如果自动超频Boost的话可以到1700MHz。而NVidia的显卡根据硬件架构的设计每个时钟周期可以执行两条指令。所以能做的浮点数运算的能力就是
对照一下官方的技术规格正好就是10.07TFLOPS。
那么最新的Intel i9 9900K的性能是多少呢不到1TFLOPS。而2080显卡和9900K的价格却是差不多的。所以在实际进行深度学习的过程中用GPU所花费的时间往往能减少一到两个数量级。而大型的深度学习模型计算往往又是多卡并行要花上几天乃至几个月。这个时候用CPU显然就不合适了。
今天随着GPGPU的推出GPU已经不只是一个图形计算设备更是一个用来做数值计算的好工具了。同样也是因为GPU的快速发展带来了过去10年深度学习的繁荣。
## 总结延伸
这一讲里面我们讲了GPU一开始是没有“可编程”能力的程序员们只能够通过配置来设计需要用到的图形渲染效果。随着“可编程管线”的出现程序员们可以在顶点处理和片段处理去实现自己的算法。为了进一步去提升GPU硬件里面的芯片利用率微软在XBox 360里面第一次引入了“统一着色器架构”使得GPU变成了一个有“通用计算”能力的架构。
接着我们从一个CPU的硬件电路出发去掉了对GPU没有什么用的分支预测和乱序执行电路来进行瘦身。之后基于渲染管线里面顶点处理和片段处理就是天然可以并行的了。我们在GPU里面可以加上很多个核。
又因为我们的渲染管线里面整个指令流程是相同的我们又引入了和CPU里的SIMD类似的SIMT架构。这个改动进一步增加了GPU里面的ALU的数量。最后为了能够让GPU不要遭遇流水线停顿我们又在同一个GPU的计算核里面加上了更多的执行上下文让GPU始终保持繁忙。
GPU里面的多核、多ALU加上多Context使得它的并行能力极强。同样架构的GPU如果光是做数值计算的话算力在同样价格的CPU的十倍以上。而这个强大计算能力以及“统一着色器架构”使得GPU非常适合进行深度学习的计算模式也就是海量计算容易并行并且没有太多的控制分支逻辑。
使用GPU进行深度学习往往能够把深度学习算法的训练时间缩短一个乃至两个数量级。而GPU现在也越来越多地用在各种科学计算和机器学习上而不仅仅是用在图形渲染上了。
## 推荐阅读
关于现代GPU的工作原理你可以仔细阅读一下 haifux.org 上的这个[PPT](http://haifux.org/lectures/267/Introduction-to-GPUs.pdf)里面图文并茂地解释了现代GPU的架构设计的思路。
## 课后思考
上面我给你算了NVidia 2080显卡的FLOPS你可以尝试算一下9900K CPU的FLOPS。
欢迎在留言区写下你的答案,你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,110 @@
<audio id="audio" title="32 | FPGA和ASIC计算机体系结构的黄金时代" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a3/11/a3e54f953272942eb6b1082dbebc5311.mp3"></audio>
过去很长一段时间里大家在讲到高科技、互联网、信息技术的时候谈的其实都是“软件”。从1995年微软发布Windows 95开始高科技似乎就等同于软件业和互联网。著名的风险投资基金Andreessen Horowitz的合伙人Marc Andreessen在2011年发表了[一篇博客](https://a16z.com/2011/08/20/why-software-is-eating-the-world/)声称“Software is Eating the World”。Marc Andreessen不仅是投资人更是Netscape的创始人之一。他当时的搭档就是我们在前两讲提过的SGI创始人Jim Clark。
的确过去20年计算机工业界的中心都在软件上。似乎硬件对大家来说慢慢变成了一个黑盒子。虽然必要但却显得有点无关紧要。
不过在上世纪7080年代计算机的世界可不是这样的。那个时候计算机工业届最激动人心的是层出不穷的硬件。无论是Intel的8086还是摩托罗拉的68000这样用于个人电脑的CPU还是直到今天大家还会提起的Macintosh还有史上最畅销的计算机Commodore 64都是在那个时代被创造出来的。
<img src="https://static001.geekbang.org/resource/image/32/3e/325bfc1e0daa59c3451a6e5361dece3e.png" alt="">
不过,随着计算机主频提升越来越困难。这几年,计算机硬件又进入了一个新的、快速发展的时期。
从树莓派基金会这样的非盈利组织开发35美元的单片机到Google这样的巨头为了深度学习专门开发出来的TPU新的硬件层出不穷也无怪乎David Patterson老爷爷去年在拿图灵奖之后专门发表[讲话](https://eecs.berkeley.edu/turing-colloquium/schedule/patterson)说计算机体系结构又进入了一个黄金时代。那今天我就带你一起来看看FPGA和ASIC这两个最近比较时髦的硬件发展。
## FPGA
之前我们讲解CPU的硬件实现的时候说过其实CPU其实就是一些简单的门电路像搭积木一样搭出来的。从最简单的门电路搭建成半加器、全加器然后再搭建成完整功能的ALU。这些电路里呢有完成各种实际计算功能的组合逻辑电路也有用来控制数据访问创建出寄存器和内存的时序逻辑电路。如果你对这块儿内容印象不深可以回顾一下第12讲到第14讲的内容以及第17讲的内容。
好了那现在我问你一个问题在我们现代CPU里面有多少个晶体管这样的电路开关呢这个答案说出来有点儿吓人。一个四核i7的Intel CPU上面的晶体管数量差不多有20亿个。那接着问题就来了我们要想设计一个CPU就要想办法连接这20亿个晶体管。
这已经够难了后面还有更难的。就像我们写程序一样连接晶体管不是一次就能完事儿了的。设计更简单一点儿的专用于特定功能的芯片少不了要几个月。而设计一个CPU往往要以“年”来计。在这个过程中硬件工程师们要设计、验证各种各样的技术方案可能会遇到各种各样的Bug。如果我们每次验证一个方案都要单独设计生产一块芯片那这个代价也太高了。
我们有没有什么办法,不用单独制造一块专门的芯片来验证硬件设计呢?能不能设计一个硬件,通过不同的程序代码,来操作这个硬件之前的电路连线,通过“编程”让这个硬件变成我们设计的电路连线的芯片呢?
<img src="https://static001.geekbang.org/resource/image/88/7a/88becfbd1d82daad6d2bfebc92809f7a.png" alt="">
这个就是我们接下来要说的FPGA也就是**现场可编程门阵列**Field-Programmable Gate Array。看到这个名字你可能要说了这里面每个单词单独我都认识放到一起就不知道是什么意思了。
没关系我们就从FPGA里面的每一个字符一个一个来看看它到底是什么意思。
- P代表Programmable这个很容易理解。也就是说这是一个可以通过编程来控制的硬件。
- G代表Gate也很容易理解它就代表芯片里面的门电路。我们能够去进行编程组合的就是这样一个一个门电路。
- A代表的Array叫作阵列说的是在一块FPGA上密密麻麻列了大量Gate这样的门电路。
- 最后一个F不太容易理解。它其实是说一块FPGA这样的板子可以在“现场”多次进行编程。它不像PALProgrammable Array Logic可编程阵列逻辑这样更古老的硬件设备只能“编程”一次把预先写好的程序一次性烧录到硬件里面之后就不能再修改了。
这么看来其实“FPGA”这样的组合基本上解决了我们前面说的想要设计硬件的问题。我们可以像软件一样对硬件编程可以反复烧录还有海量的门电路可以组合实现复杂的芯片功能。
不过相信你和我一样好奇我们究竟怎么对硬件进行编程呢我们之前说过CPU其实就是通过晶体管来实现各种组合逻辑或者时序逻辑。那么我们怎么去“编程”连接这些线路呢
FPGA的解决方案很精巧我把它总结为这样三个步骤。
**第一,用存储换功能实现组合逻辑。**在实现CPU的功能的时候我们需要完成各种各样的电路逻辑。在FPGA里这些基本的电路逻辑不是采用布线连接的方式进行的而是预先根据我们在软件里面设计的逻辑电路算出对应的真值表然后直接存到一个叫作LUTLook-Up Table查找表的电路里面。这个LUT呢其实就是一块存储空间里面存储了“特定的输入信号下对应输出0还是1”。
<img src="https://static001.geekbang.org/resource/image/da/1e/da9a35820e7c1038c30ad54390888a1e.jpeg" alt="">
如果还没理解你可以想一下这个问题。假如现在我们要实现一个函数这个函数需要返回斐波那契数列的第N项并且限制这个N不会超过100。该怎么解决这个问题呢
斐波那契数列的通项公式是 f(N) = f(N-1) + f(N-2) 。所以我们的第一种办法自然是写一个程序从第1项开始算。但其实还有一种办法就是我们预先用程序算好斐波那契数量前100项然后把它预先放到一个数组里面。这个数组就像 [1, 1, 2, 3, 5…] 这样。当要计算第N项的时候呢我们并不是去计算得到结果而是直接查找这个数组里面的第N项。
这里面的关键就在于这个查表的办法不只能够提供斐波那契数列。如果我们要有一个获得N的5次方的函数一样可以先计算好放在表里面进行查询。这个“查表”的方法其实就是FPGA通过LUT来实现各种组合逻辑的办法。
**第二对于需要实现的时序逻辑电路我们可以在FPGA里面直接放上D触发器作为寄存器。**这个和CPU里的触发器没有什么本质不同。不过我们会把很多个LUT的电路和寄存器组合在一起变成一个叫作逻辑簇Logic Cluster的东西。在FPGA里这样组合了多个LUT和寄存器的设备也被叫做CLBConfigurable Logic Block可配置逻辑块
我们通过配置CLB实现的功能有点儿像我们前面讲过的全加器。它已经在最基础的门电路上做了组合能够提供更复杂一点的功能。更复杂的芯片功能我们不用再从门电路搭起可以通过CLB组合搭建出来。
<img src="https://static001.geekbang.org/resource/image/32/00/325f24043bf68651063dd5b907ab3300.jpeg" alt="">
**第三FPGA是通过可编程逻辑布线来连接各个不同的CLB最终实现我们想要实现的芯片功能。**这个可编程逻辑布线你可以把它当成我们的铁路网。整个铁路系统已经铺好了但是整个铁路网里面设计了很多个道岔。我们可以通过控制道岔来确定不同的列车线路。在可编程逻辑布线里面“编程”在做的就是拨动像道岔一样的各个电路开关最终实现不同CLB之间的连接完成我们想要的芯片功能。
于是通过LUT和寄存器我们能够组合出很多CLB而通过连接不同的CLB最终有了我们想要的芯片功能。最关键的是这个组合过程是可以“编程”控制的。而且这个编程出来的软件还可以后续改写重新写入到硬件里。让同一个硬件实现不同的芯片功能。从这个角度来说FPGA也是“软件吞噬世界”的一个很好的例子。
## ASIC
除了CPU、GPU以及刚刚的FPGA我们其实还需要用到很多其他芯片。比如现在手机里就有专门用在摄像头里的芯片录音笔里会有专门处理音频的芯片。尽管一个CPU能够处理好手机拍照的功能也能处理好录音的功能但是我们直接在手机或者录音笔里塞上一个Intel CPU显然比较浪费。
于是,我们就考虑为这些有专门用途的场景,单独设计一个芯片。这些专门设计的芯片呢,我们称之为**ASIC**Application-Specific Integrated Circuit也就是**专用集成电路**。事实上过去几年ASIC发展得特别快。因为ASIC是针对专门用途设计的所以它的电路更精简单片的制造成本也比CPU更低。而且因为电路精简所以通常能耗要比用来做通用计算的CPU更低。而我们上一讲所说的早期的图形加速卡其实就可以看作是一种ASIC。
因为ASIC的生产制造成本以及能耗上的优势过去几年里有不少公司设计和开发ASIC用来“挖矿”。这个“挖矿”说的其实就是设计专门的数值计算芯片用来“挖”比特币、ETH这样的数字货币。
那么我们能不能用刚才说的FPGA来做ASIC的事情呢当然是可以的。我们对FPGA进行“编程”其实就是把FPGA的电路变成了一个ASIC。这样的芯片往往在成本和功耗上优于需要做通用计算的CPU和GPU。
那你可能又要问了那为什么我们干脆不要用ASIC了全都用FPGA不就好了么你要知道其实FPGA一样有缺点那就是它的硬件上有点儿“浪费”。这个很容易理解我一说你就明白了。
每一个LUT电路其实都是一个小小的“浪费”。一个LUT电路设计出来之后既可以实现与门又可以实现或门自然用到的晶体管数量比单纯连死的与门或者或门的要多得多。同时因为用的晶体管多它的能耗也比单纯连死的电路要大单片FPGA的生产制造的成本也比ASIC要高不少。
当然有缺点就有优点FPGA的优点在于它没有硬件研发成本。ASIC的电路设计需要仿真、验证还需要经过流片Tape out变成一个印刷的电路版最终变成芯片。这整个从研发到上市的过程最低花费也要几万美元高的话会在几千万乃至数亿美元。更何况整个设计还有失败的可能。所以如果我们设计的专用芯片只是要制造几千片那买几千片现成的FPGA可能远比花上几百万美元来设计、制造ASIC要经济得多。
实际上到底使用ASIC这样的专用芯片还是采用FPGA这样可编程的通用硬件核心的决策因素还是成本。不过这个成本不只是单个芯片的生产制造成本还要考虑**总体拥有成本**Total Cost of Ownership也就是说除了生产成本之外我们要把研发成本也算进去。如果我们只制造了一片芯片那么成本就是“这枚芯片的成本+为了这枚芯片建的生产线的成本+芯片的研发成本”,而不只是“芯片的原材料沙子的成本+生产的电费”。
单个ASIC的生产制造成本比FPGA低ASIC的能耗也比能实现同样功能的FPGA要低。能耗低意味着长时间运行这些芯片所用的电力成本也更低。
但是ASIC有一笔很高的NRENon-Recuring Engineering Cost一次性工程费用成本。这个成本就是ASIC实际“研发”的成本。只有需要大量生产ASIC芯片的时候我们才能摊薄这份研发成本。
<img src="https://static001.geekbang.org/resource/image/4c/34/4cbada39aeedefd718eca8c329797834.jpeg" alt="">
其实,在我们的日常软件开发过程中,也需要做同样的决策。很多我们需要的功能,可能在市面上已经有开源的软件可以实现。我们可以在开源的软件之上做配置或者开发插件,也可以选择自己从头开始写代码。
在开源软件或者是买来的商业软件上启动往往能很快让产品上线。如果从头开始写代码往往会有一笔不地的NRE成本也就是研发成本。但是通常我们自己写的代码能够100%贴近我们的业务需求后续随着业务需求的改造成本会更低。如果要大规模部署很多服务器的话服务器的成本会更低。学会从TCO和NRE的成本去衡量做决策也是每一个架构师的必修课。
## 总结延伸
好了这一讲里我为你介绍了FPGA和ASIC这两种近年来非常时髦的芯片。
FPGA本质上是一个可以通过编程来控制硬件电路的芯片。我们通过用LUT这样的存储设备来代替需要的硬连线的电路有了可编程的逻辑门然后把很多LUT和寄存器放在一起变成一个更复杂的逻辑电路也就是CLB然后通过控制可编程布线中的很多开关最终设计出属于我们自己的芯片功能。FPGA常常被我们用来进行芯片的设计和验证工作也可以直接拿来当成专用的芯片替换掉CPU或者GPU以节约成本。
相比FPGAASIC在“专用”上更进一步。它是针对特定的使用场景设计出来的芯片比如摄像头、音频、“挖矿”或者深度学习。虽然ASIC的研发成本高昂但是生产制造成本和能耗都很低。所以对于有大量需求的专用芯片用ASIC是很划得来的。而在FPGA和ASIC之间进行取舍就要看两者的整体拥有成本哪一个更低了。
专用芯片的故事我们还没有讲完下一讲我们来看看Google开发的TPU这个近两年最知名的ASIC芯片的故事。
## 推荐阅读
既然用了David Patterson老爷爷的“黄金时代”作为这一讲的标题那么他的这篇讲话不可不读。我在文稿里留下了对应的[文章链接](https://cacm.acm.org/magazines/2019/2/234352-a-new-golden-age-for-computer-architecture/fulltext),你也可以在网络上看对应的[讲话视频和PPT。](https://eecs.berkeley.edu/turing-colloquium/schedule/patterson)
## 课后思考
最后给你留一道思考题。除了我今天讲到的FPGA、ASIC之外你最近关注到什么新的、有意思的硬件呢
欢迎在留言区分享出来。让我们不只了解计算机“软件”,也能够看到更广阔的“硬件”世界。同时,如果你觉得今天的内容很有收获,你也可以把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,109 @@
<audio id="audio" title="33 | 解读TPU设计和拆解一块ASIC芯片" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/3f/cd9de2b417f2eb039e7c79cc0f4f7a3f.mp3"></audio>
过去几年最知名、最具有实用价值的ASIC就是TPU了。各种解读TPU论文内容的文章网上也很多。不过这些文章更多地是从机器学习或者AI的角度来讲解TPU。
上一讲我为你讲解了FPGA和ASIC讲解了FPGA如何实现通过“软件”来控制“硬件”以及我们可以进一步把FPGA设计出来的电路变成一块ASIC芯片。
不过呢这些似乎距离我们真实的应用场景有点儿远。我们怎么能够设计出来一块有真实应用场景的ASIC呢如果要去设计一块ASIC我们应该如何思考和拆解问题呢今天我就带着你一起学习一下如何设计一块专用芯片。
## TPU V1想要解决什么问题
黑格尔说“世上没有无缘无故的爱也没有无缘无故的恨”。第一代TPU的设计并不是异想天开的创新而是来自于真实的需求。
从2012年解决计算机视觉问题开始深度学习一下子进入了大爆发阶段也一下子带火了GPUNVidia的股价一飞冲天。我们在[第31讲](https://time.geekbang.org/column/article/105401)讲过GPU天生适合进行海量、并行的矩阵数值计算于是它被大量用在深度学习的模型训练上。
不过你有没有想过,在深度学习热起来之后,计算量最大的是什么呢?并不是进行深度学习的训练,而是深度学习的推断部分。
所谓**推断部分**,是指我们在完成深度学习训练之后,把训练完成的模型存储下来。这个存储下来的模型,是许许多多个向量组成的参数。然后,我们根据这些参数,去计算输入的数据,最终得到一个计算结果。这个推断过程,可能是在互联网广告领域,去推测某一个用户是否会点击特定的广告;也可能是我们在经过高铁站的时候,扫一下身份证进行一次人脸识别,判断一下是不是你本人。
虽然训练一个深度学习的模型需要花的时间不少但是实际在推断上花的时间要更多。比如我们上面说的高铁去年2018年一年就有20亿人次坐了高铁这也就意味着至少进行了20亿次的人脸识别“推断“工作。
所以第一代的TPU首先优化的并不是深度学习的模型训练而是深度学习的模型推断。这个时候你可能要问了那模型的训练和推断有什么不同呢主要有三个点。
**第一点,深度学习的推断工作更简单,对灵活性的要求也就更低。**模型推断的过程我们只需要去计算一些矩阵的乘法、加法调用一些Sigmoid或者RELU这样的激活函数。这样的过程可能需要反复进行很多层但是也只是这些计算过程的简单组合。
**第二点,深度学习的推断的性能,首先要保障响应时间的指标。**我们在[第4讲](https://time.geekbang.org/column/article/93246)讲过计算机关注的性能指标有响应时间Response Time和吞吐率Throughput。我们在模型训练的时候只需要考虑吞吐率问题就行了。因为一个模型训练少则好几分钟多的话要几个月。而推断过程像互联网广告的点击预测我们往往希望能在几十毫秒乃至几毫秒之内就完成而人脸识别也不希望会超过几秒钟。很显然模型训练和推断对于性能的要求是截然不同的。
**第三点,深度学习的推断工作,希望在功耗上尽可能少一些**。深度学习的训练对功耗没有那么敏感只是希望训练速度能够尽可能快多费点电就多费点儿了。这是因为深度学习的推断要7×24h地跑在数据中心里面。而且对应的芯片要大规模地部署在数据中心。一块芯片减少5%的功耗,就能节省大量的电费。而深度学习的训练工作,大部分情况下只是少部分算法工程师用少量的机器进行。很多时候,只是做小规模的实验,尽快得到结果,节约人力成本。少数几台机器多花的电费,比起算法工程师的工资来说,只能算九牛一毛了。
这三点的差别也就带出了第一代TPU的设计目标。那就是在保障响应时间的情况下能够尽可能地提高**能效比**这个指标也就是进行同样多数量的推断工作花费的整体能源要显著低于CPU和GPU。
## 深入理解TPU V1
### 快速上线和向前兼容一个FPU的设计
如果你来设计TPU除了满足上面的深度学习的推断特性之外还有什么是你要重点考虑的呢你可以停下来思考一下然后再继续往下看。
不知道你的答案是什么我的第一反应是有两件事情必须要考虑第一个是TPU要有向前兼容性第二个是希望TPU能够尽早上线。我下面说说我考虑这两点的原因。
<img src="https://static001.geekbang.org/resource/image/f6/5e/f6637990792e8de1ef84891fadd11e5e.png" alt="">
第一点,向前兼容。在计算机产业界里,因为没有考虑向前兼容,惨遭失败的产品数不胜数。典型的有我在[第26讲](https://time.geekbang.org/column/article/102888)提过的安腾处理器。所以TPU并没有设计成一个独立的“CPU“而是设计成一块像显卡一样插在主板PCI-E接口上的板卡。更进一步地TPU甚至没有像我们之前说的现代GPU一样设计成自己有对应的取指令的电路而是通过CPU向TPU发送需要执行的指令。
这两个设计使得我们的TPU的硬件设计变得简单了我们只需要专心完成一个专用的“计算芯片”就好了。所以TPU整个芯片的设计上线时间也就缩短到了15个月。不过这样一个TPU其实是第26讲里我们提过的387浮点数计算芯片是一个像FPU浮点数处理器的协处理器Coprocessor而不是像CPU和GPU这样可以独立工作的Processor Unit。
### 专用电路和大量缓存,适应推断的工作流程
明确了TPU整体的设计思路之后我们可以来看一看TPU内部有哪些芯片和数据处理流程。我在文稿里面放了TPU的模块图和对应的芯片布局图你可以对照着看一下。
<img src="https://static001.geekbang.org/resource/image/6a/ae/6a14254b2bda4dd42adac6a2129e8bae.jpeg" alt="">
你可以看到在芯片模块图里面有单独的矩阵乘法单元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是因为在整个的推断过程中它会高频反复地被矩阵乘法单元读写来完成计算。
<img src="https://static001.geekbang.org/resource/image/08/2a/08e29a700898e5dabf60fbf0f026082a.jpeg" alt="">
可以看到整个TPU里面每一个组件的设计完全是为了深度学习的推断过程设计出来的。这也是我们设计开发ASIC的核心原因用特制的硬件最大化特定任务的运行效率。
### 细节优化使用8 Bits数据
除了整个TPU的模块设计和芯片布局之外TPU在各个细节上也充分考虑了自己的应用场景我们可以拿里面的矩阵乘法单元Matrix Multiply Unit来作为一个例子。
如果你仔细一点看的话会发现这个矩阵乘法单元没有用32 Bits来存放一个浮点数而是只用了一个8 Bits来存放浮点数。这是因为在实践的机器学习应用中会对数据做[归一化](https://en.wikipedia.org/wiki/Normalization)Normalization和[正则化](https://en.wikipedia.org/wiki/Regularization_(mathematics))Regularization的处理。咱们毕竟不是一个机器学习课所以我就不深入去讲什么是归一化和正则化了你只需要知道这两个操作呢会使得我们在深度学习里面操作的数据都不会变得太大。通常来说呢都能控制在-3到3这样一定的范围之内。
因为这个数值上的特征,我们需要的浮点数的精度也不需要太高了。我们在[第16讲](https://time.geekbang.org/column/article/98312)讲解浮点数的时候说过32位浮点数的精度差不多可以到1/1600万。如果我们用8位或者16位表示浮点数也能把精度放到2^6或者2^12也就是1/64或者1/4096。在深度学习里常常够用了。特别是在模型推断的时候要求的计算精度往往可以比模型训练低。所以8 Bits的矩阵乘法器就可以放下更多的计算量使得TPU的推断速度更快。
## 用数字说话TPU的应用效果
那么综合了这么多优秀设计点的TPU实际的使用效果怎么样呢不管设计得有多好最后还是要拿效果和数据说话。俗话说是骡子是马总要拿出来溜溜啊。
Google在TPU的论文里面给出了答案。一方面在性能上TPU比现在的CPU、GPU在深度学习的推断任务上要快1530倍。而在能耗比上更是好出3080倍。另一方面Google已经用TPU替换了自家数据中心里95%的推断任务,可谓是拿自己的实际业务做了一个明证。
## 总结延伸
这一讲我从第一代TPU的设计目标讲起为你解读了TPU的设计。你可以通过这篇文章回顾我们过去32讲提到的各种知识点。
第一代TPU是为了做各种深度学习的推断而设计出来的并且希望能够尽早上线。这样Google才能节约现有数据中心里面的大量计算资源。
从深度学习的推断角度来考虑TPU并不需要太灵活的可编程能力只要能够迭代完成常见的深度学习推断过程中一层的计算过程就好了。所以TPU的硬件构造里面把矩阵乘法、累加器和激活函数都做成了对应的专门的电路。
为了满足深度学习推断功能的响应时间短的需求TPU设置了很大的使用SRAM的Unified BufferUB就好像一个CPU里面的寄存器一样能够快速响应对于这些数据的反复读取。
为了让TPU尽可能快地部署在数据中心里面TPU采用了现有的PCI-E接口可以和GPU一样直接插在主板上并且采用了作为一个没有取指令功能的协处理器就像387之于386一样仅仅用来进行需要的各种运算。
在整个电路设计的细节层面TPU也尽可能做到了优化。因为机器学习的推断功能通常做了数值的归一化所以对于矩阵乘法的计算精度要求有限整个矩阵乘法的计算模块采用了8 Bits来表示浮点数而不是像Intel CPU里那样用上了32 Bits。
最终综合了种种硬件设计点之后的TPU做到了在深度学习的推断层面更高的能效比。按照Google论文里面给出的官方数据它可以比CPU、GPU快上1530倍能耗比更是可以高出3080倍。而TPU也最终替代了Google自己的数据中心里95%的深度学习推断任务。
## 推荐阅读
既然要深入了解TPU自然要读一读关于TPU的论文[In-Datacenter Performance Analysis of a Tensor Processing Unit](https://arxiv.org/ftp/arxiv/papers/1704/1704.04760.pdf)。
除了这篇论文之外你也可以读一读Google官方专门讲解TPU构造的博客文章 [An in-depth look at Googles first Tensor Processing Unit(TPU)](https://cloud.google.com/blog/products/gcp/an-in-depth-look-at-googles-first-tensor-processing-unit-tpu)。
## 课后思考
你能想一想如果我们想要做一个能够进行深度学习模型训练的TPU我们应该在第一代的TPU的设计之上做怎么样的修改呢
欢迎留言和我分享你的想法。如果这篇文章对你有收获,你也可以把他分享给你的朋友。

View File

@@ -0,0 +1,117 @@
<audio id="audio" title="34 | 理解虚拟机:你在云上拿到的计算机是什么样的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/1b/a13c711ce3e3bf389bffd10b90ddb81b.mp3"></audio>
上世纪60年代计算机还是异常昂贵的设备实际的计算机使用需求要面临两个挑战。第一计算机特别昂贵我们要尽可能地让计算机忙起来一直不断地去处理一些计算任务。第二很多工程师想要用上计算机但是没有能力自己花钱买一台所以呢我们要让很多人可以共用一台计算机。
## 缘起分时系统
为了应对这两个问题,[分时系统](https://en.wikipedia.org/wiki/Time-sharing)的计算机就应运而生了。
无论是个人用户还是一个小公司或者小机构你都不需要花大价钱自己去买一台电脑。你只需要买一个输入输出的终端就好像一套鼠标、键盘、显示器这样的设备然后通过电话线连到放在大公司机房里面的计算机就好了。这台计算机会自动给程序或任务分配计算时间。你只需要为你花费的“计算时间”和使用的电话线路付费就可以了。比方说比尔·盖茨中学时候用的学校的计算机就是GE的分时系统。
<img src="https://static001.geekbang.org/resource/image/d1/9d/d107e645e1f849ebcafab0e4d4b73a9d.png" alt="">
## 从“黑色星期五”到公有云
现代公有云上的系统级虚拟机能够快速发展,其实和分时系统的设计思路是一脉相承的,这其实就是来自于电商巨头亚马逊大量富余的计算能力。
和国内有“双十一”一样,美国会有感恩节的“[黑色星期五](https://en.wikipedia.org/wiki/Black_Friday_(shopping))Black Friday”和“[网络星期一](https://en.wikipedia.org/wiki/Cyber_Monday)Cyber Monday这样一年一度的大型电商促销活动。几天的活动期间会有大量的用户进入亚马逊这样的网站看商品、下订单、买东西。这个时候整个亚马逊需要的服务器计算资源可能是平时的数十倍。
于是亚马逊会按照“黑色星期五”和“网络星期一”的用户访问量来准备服务器资源。这个就带来了一个问题那就是在一年的365天里有360天这些服务器资源是大量空闲的。要知道这个空闲的服务器数量不是一台两台也不是几十几百台。根据媒体的估算亚马逊的云服务器AWS在2014年就已经超过了150万台到了2019年的今天估计已经有超过千万台的服务器。
平时有这么多闲着的服务器实在是太浪费了,所以,亚马逊就想把这些服务器给租出去。出租物理服务器当然是可行的,但是却不太容易自动化,也不太容易面向中小客户。
直接出租物理服务器意味着亚马逊只能进行服务器的“整租”这样大部分中小客户就不愿意了。为了节约数据中心的空间亚马逊实际用的物理服务器大部分多半是强劲的高端8核乃至12核的服务器。想要租用这些服务器的中小公司起步往往只需要1个CPU核心乃至更少资源的服务器。一次性要他们去租一整台服务器就好像刚毕业想要租个单间结果你非要整租个别墅给他。
这个“整租”的问题,还发生在“时间”层面。物理服务器里面装好的系统和应用,不租了而要再给其他人使用,就必须清空里面已经装好的程序和数据,得做一次“重装”。如果我们只是暂时不用这个服务器了,过一段时间又要租这个服务器,数据中心服务商就不得不先重装整个系统,然后租给别人。等别人不用了,再重装系统租给你,特别地麻烦。
其实,对于想要租用服务器的用户来说,最好的体验不是租房子,而是住酒店。我住一天,我就付一天的钱。这次是全家出门,一次多定几间酒店房间就好啦。
而这样的需求,用虚拟机技术来实现,再好不过了。虚拟机技术,使得我们可以在一台物理服务器上,同时运行多个虚拟服务器,并且可以动态去分配,每个虚拟服务器占用的资源。对于不运行的虚拟服务器,我们也可以把这个虚拟服务器“关闭”。这个“关闭”了的服务器,就和一个被关掉的物理服务器一样,它不会再占用实际的服务器资源。但是,当我们重新打开这个虚拟服务器的时候,里面的数据和应用都在,不需要再重新安装一次。
## 虚拟机的技术变迁
那虚拟机技术到底是怎么一回事呢?下面我带你具体来看一看,它的技术变迁过程,好让你能更加了解虚拟机,从而更好地使用它。
**虚拟机**Virtual Machine技术其实就是指在现有硬件的操作系统上能够**模拟**一个计算机系统的技术。而模拟一个计算机系统最简单的办法其实不能算是虚拟机技术而是一个模拟器Emulator
### 解释型虚拟机
要模拟一个计算机系统,最简单的办法,就是兼容这个计算机系统的指令集。我们可以开发一个应用程序,跑在我们的操作系统上。这个应用程序呢,可以识别我们想要模拟的、计算机系统的程序格式和指令,然后一条条去解释执行。
在这个过程中,我们把原先的操作系统叫作**宿主机**Host把能够有能力去模拟指令执行的软件叫作**模拟器**Emulator而实际运行在模拟器上被“虚拟”出来的系统呢我们叫**客户机**Guest VM
这个方式其实和运行Java程序的Java虚拟机很像。只不过Java虚拟机运行的是Java自己定义发明的中间代码而不是一个特定的计算机系统的指令。
这种解释执行另一个系统的方式有没有真实的应用案例呢当然是有的如果你是一个Android开发人员你在开发机上跑的Android模拟器其实就是这种方式。如果你喜欢玩一些老游戏可以注意研究一下很多能在Windows下运行的游戏机模拟器用的也是类似的方式。
**这种解释执行方式的最大的优势就是,模拟的系统可以跨硬件。**比如Android手机用的CPU是ARM的而我们的开发机用的是Intel X86的两边的CPU指令集都不一样但是一样可以正常运行。如果你想玩的街机游戏里面的硬件早就已经停产了那你自然只能选择MAME这样的模拟器。
<img src="https://static001.geekbang.org/resource/image/91/6b/912a6b83c639ae06b61a56327488916b.png" alt="">
不过这个方式也有两个明显的缺陷。第一个是我们做不到精确的“模拟”。很多的老旧的硬件的程序运行要依赖特定的电路乃至电路特有的时钟频率想要通过软件达到100%模拟是很难做到的。第二个缺陷就更麻烦了那就是这种解释执行的方式性能实在太差了。因为我们并不是直接把指令交给CPU去执行的而是要经过各种解释和翻译工作。
所以虽然模拟器这样的形式有它的实际用途。甚至为了解决性能问题也有类似于Java当中的JIT这样的“编译优化”的办法把本来解释执行的指令编译成Host可以直接运行的指令。但是这个性能还是不能让人满意。毕竟我们本来是想要把空余的计算资源租用出去的。如果我们空出来的计算能力算是个大平层结果经过模拟器之后能够租出去的计算能力就变成了一个格子间那我们就划不来了。
### Type-1和Type-2虚拟机的性能提升
所以我们希望我们的虚拟化技术能够克服上面的模拟器方式的两个缺陷。同时我们可以放弃掉模拟器方式能做到的跨硬件平台的这个能力。因为毕竟对于我们想要做的云服务里的“服务器租赁”业务来说中小客户想要租的也是一个x86的服务器。而另外一方面他们希望这个租用的服务器用起来和直接买一台或者租一台物理服务器没有区别。作为出租方的我们也希望服务器不要因为用了虚拟化技术而在中间损耗掉太多的性能。
所以首先我们需要一个“全虚拟化”的技术也就是说我们可以在现有的物理服务器的硬件和操作系统上去跑一个完整的、不需要做任何修改的客户机操作系统Guest OS。那么我们怎么在一个操作系统上再去跑多个完整的操作系统呢答案就是我们自己做软件开发中很常用的一个解决方案就是加入一个中间层。在虚拟机技术里面这个中间层就叫作**虚拟机监视器**英文叫VMMVirtual Machine Manager或者Hypervisor。
<img src="https://static001.geekbang.org/resource/image/e0/bf/e09b64e035a3b1bd664b0584a7b52fbf.jpeg" alt="">
如果说我们宿主机的OS是房东的话这个虚拟机监视器呢就好像一个二房东。我们运行的虚拟机都不是直接和房东打交道而是要和这个二房东打交道。我们跑在上面的虚拟机呢会把整个的硬件特征都映射到虚拟机环境里这包括整个完整的CPU指令集、I/O操作、中断等等。
既然要通过虚拟机监视器这个二房东我们实际的指令是怎么落到硬件上去实际执行的呢这里有两种办法也就是Type-1和Type-2这两种类型的虚拟机。
我们先来看Type-2类型的虚拟机。在Type-2虚拟机里我们上面说的虚拟机监视器好像一个运行在操作系统上的软件。你的客户机的操作系统呢把最终到硬件的所有指令都发送给虚拟机监视器。而虚拟机监视器又会把这些指令再交给宿主机的操作系统去执行。
那这时候你就会问了这和上面的模拟器看起来没有那么大分别啊看起来我们只是把在模拟器里的指令翻译工作挪到了虚拟机监视器里。没错Type-2型的虚拟机更多是用在我们日常的个人电脑里而不是用在数据中心里。
在数据中心里面用的虚拟机我们通常叫作Type-1型的虚拟机。这个时候客户机的指令交给虚拟机监视器之后呢不再需要通过宿主机的操作系统才能调用硬件而是可以直接由虚拟机监视器去调用硬件。
另外在数据中心里面我们并不需要在Intel x86上面去跑一个ARM的程序而是直接在x86上虚拟一个x86硬件的计算机和操作系统。所以我们的指令不需要做什么翻译工作可以直接往下传递执行就好了所以指令的执行效率也会很高。
所以在Type-1型的虚拟机里我们的虚拟机监视器其实并不是一个操作系统之上的应用层程序而是一个嵌入在操作系统内核里面的一部分。无论是KVM、XEN还是微软自家的Hyper-V其实都是系统级的程序。
<img src="https://static001.geekbang.org/resource/image/9c/8e/9c30f8d93270a9563154aa732b9c9f8e.jpeg" alt="">
因为虚拟机监视器需要直接和硬件打交道所以它也需要包含能够直接操作硬件的驱动程序。所以Type-1的虚拟机监视器更大一些同时兼容性也不能像Type-2型那么好。不过因为它一般都是部署在我们的数据中心里面硬件完全是统一可控的这倒不是一个问题了。
### Docker新时代的最佳选择
虽然Type-1型的虚拟机看起来已经没有什么硬件损耗。但是这里面还是有一个浪费的资源。在我们实际的物理机上我们可能同时运行了多个的虚拟机而这每一个虚拟机都运行了一个属于自己的单独的操作系统。
多运行一个操作系统意味着我们要多消耗一些资源在CPU、内存乃至磁盘空间上。那我们能不能不要多运行的这个操作系统呢
其实是可以的。因为我们想要的未必是一个完整的、独立的、全虚拟化的虚拟机。我们很多时候想要租用的不是“独立服务器”而是独立的计算资源。在服务器领域我们开发的程序都是跑在Linux上的。其实我们并不需要一个独立的操作系统只要一个能够进行资源和环境隔离的“独立空间”就好了。那么能够满足这个需求的解决方案就是过去几年特别火热的Docker技术。使用Docker来搭建微服务可以说是过去两年大型互联网公司的必经之路了。
<img src="https://static001.geekbang.org/resource/image/6c/35/6cbf5f5f4275bc053fabcd3480304a35.jpeg" alt="">
在实践的服务器端的开发中虽然我们的应用环境需要各种各样不同的依赖可能是不同的PHP或者Python的版本可能是操作系统里面不同的系统库但是通常来说我们其实都是跑在Linux内核上的。通过Docker我们不再需要在操作系统上再跑一个操作系统而只需要通过容器编排工具比如Kubernetes或者Docker Swarm能够进行各个应用之间的环境和资源隔离就好了。
这种隔离资源的方式呢也有人称之为“操作系统级虚拟机”好和上面的全虚拟化虚拟机对应起来。不过严格来说Docker并不能算是一种虚拟机技术而只能算是一种资源隔离的技术而已。
## 总结延伸
这一讲,我从最古老的分时系统讲起,介绍了虚拟机的相关技术。我们现在的云服务平台上,你能够租到的服务器其实都是虚拟机,而不是物理机。而正是虚拟机技术的出现,使得整个云服务生态得以出现。
虚拟机是模拟一个计算机系统的技术而其中最简单的办法叫模拟器。我们日常在PC上进行Android开发其实就是在使用这样的模拟器技术。不过模拟器技术在性能上实在不行所以我们才有了虚拟化这样的技术。
在宿主机的操作系统上运行一个虚拟机监视器然后再在虚拟机监视器上运行客户机的操作系统这就是现代的虚拟化技术。这里的虚拟化技术可以分成Type-1和Type-2这两种类型。
Type-1类型的虚拟化机实际的指令不需要再通过宿主机的操作系统而可以直接通过虚拟机监视器访问硬件所以性能比Type-2要好。而Type-2类型的虚拟机所有的指令需要经历客户机操作系统、虚拟机监视器、宿主机操作系统所以性能上要慢上不少。不过因为经历了宿主机操作系统的一次“翻译”过程它的硬件兼容性往往会更好一些。
今天即使是Type-1型的虚拟机技术我们也会觉得有一些性能浪费。我们常常在同一个物理机上跑上8个、10个的虚拟机。而且这些虚拟机的操作系统其实都是同一个Linux Kernel的版本。于是轻量级的Docker技术就进入了我们的视野。Docker也被很多人称之为“操作系统级”的虚拟机技术。不过Docker并没有再单独运行一个客户机的操作系统而是直接运行在宿主机操作系统的内核之上。所以Docker也是现在流行的微服务架构底层的基础设施。
## 推荐阅读
又到了阅读英文文章的时间了。想要更多了解虚拟机、Docker这些相关技术的概念和知识特别是进一步理解Docker的细节你可以去读一读FreeCodeCamp里的[A Beginner-Friendly Introduction to Containers, VMs and Docker](https://www.freecodecamp.org/news/a-beginner-friendly-introduction-to-containers-vms-and-docker-79a9e3e119b/)这篇文章。
## 课后思考
我们在程序开发过程中除了会用今天讲到的系统级虚拟机之外还会常常遇到Java虚拟机这样的进程级虚拟机。那么JVM这个进程级虚拟机是为了解决什么问题而出现的呢今天我们讲到的系统级虚拟机发展历程中的各种优化手段有哪些是JVM中也可以通用的呢
欢迎留言和我分享你的疑惑和见解。如果有收获,你也可以把今天的文章分享给你朋友。